@kabmo-labs/kabmo 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -20,6 +20,7 @@ import type { ComposerModes } from '@/client/components/workspace/chat-timeline/
|
|
|
20
20
|
import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
21
21
|
import type { DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
22
22
|
import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
|
|
23
|
+
import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
|
|
23
24
|
|
|
24
25
|
export interface PendingChatViewProps {
|
|
25
26
|
className: string;
|
|
@@ -59,6 +60,10 @@ export interface PendingChatViewProps {
|
|
|
59
60
|
onWaitingForChange: (candidate: WaitingCandidate | null) => void;
|
|
60
61
|
heldWait: { title: string | null } | null;
|
|
61
62
|
onCancelWait: () => void;
|
|
63
|
+
/** Where the chat will run: the folder, or one of its worktrees. */
|
|
64
|
+
spawnTarget?: SpawnTarget | null;
|
|
65
|
+
onSpawnTargetChange?: (next: SpawnTarget | null) => void;
|
|
66
|
+
onWorktreeReport?: (message: string) => void;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
export function PendingChatView(props: PendingChatViewProps) {
|
|
@@ -101,6 +106,9 @@ export function PendingChatView(props: PendingChatViewProps) {
|
|
|
101
106
|
onWaitingForChange={props.onWaitingForChange}
|
|
102
107
|
heldWait={props.heldWait}
|
|
103
108
|
onCancelWait={props.onCancelWait}
|
|
109
|
+
spawnTarget={props.spawnTarget}
|
|
110
|
+
onSpawnTargetChange={props.onSpawnTargetChange}
|
|
111
|
+
onWorktreeReport={props.onWorktreeReport}
|
|
104
112
|
/>
|
|
105
113
|
);
|
|
106
114
|
}
|
|
@@ -88,6 +88,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
88
88
|
extensionDialogs,
|
|
89
89
|
resolveExtensionDialog,
|
|
90
90
|
respondToExtensionUi,
|
|
91
|
+
spawnTarget,
|
|
92
|
+
setSpawnTarget,
|
|
91
93
|
} = useChatTimeline({ folders, streamStatusMap, appSettings, reportActionError: pushToast });
|
|
92
94
|
|
|
93
95
|
// Ask dialogs render on their own tool card; anything else omp is blocked on
|
|
@@ -215,6 +217,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
215
217
|
onWaitingForChange={waitingDependency.set}
|
|
216
218
|
heldWait={heldWait ? { title: heldWait.dependsOnTitle } : null}
|
|
217
219
|
onCancelWait={cancelHeldWait}
|
|
220
|
+
spawnTarget={spawnTarget}
|
|
221
|
+
onSpawnTargetChange={setSpawnTarget}
|
|
222
|
+
onWorktreeReport={(message) => pushToast(message, 'success', { duration: 6000 })}
|
|
218
223
|
/>
|
|
219
224
|
);
|
|
220
225
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composer's target picker and the New Worktree dialog, rendered.
|
|
8
|
+
*
|
|
9
|
+
* Unit-testing markup is usually a test of the test, so these cases are the ones
|
|
10
|
+
* a reader would otherwise discover in a browser and nowhere else:
|
|
11
|
+
*
|
|
12
|
+
* - the trigger names the WORKTREE when one is picked, because "kabmo" alone
|
|
13
|
+
* would be equally true of the checkout it is not running in;
|
|
14
|
+
* - the menu's groups are the three the plan names, and creating is offered
|
|
15
|
+
* ABOVE the list so a long list cannot hide it;
|
|
16
|
+
* - the dialog opens on a generated branch with the folder following it, and
|
|
17
|
+
* the folder keeps following until it is edited (the defect this rule
|
|
18
|
+
* prevents: a folder named after the previous branch).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
22
|
+
import { Window } from 'happy-dom';
|
|
23
|
+
import { h, render } from 'preact';
|
|
24
|
+
import { act } from 'preact/test-utils';
|
|
25
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
26
|
+
import { TargetPicker, targetLabel } from '@/client/components/workspace/chat-timeline/target-picker/index';
|
|
27
|
+
import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
|
|
28
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
29
|
+
|
|
30
|
+
const folder: WorkspaceFolderData = {
|
|
31
|
+
id: 1,
|
|
32
|
+
name: 'kabmo',
|
|
33
|
+
project_path: '/repo',
|
|
34
|
+
isExpanded: true,
|
|
35
|
+
hasMore: false,
|
|
36
|
+
totalSessions: 0,
|
|
37
|
+
sessions: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const nativeFetch = Bun.fetch;
|
|
41
|
+
const mounted: HTMLElement[] = [];
|
|
42
|
+
|
|
43
|
+
beforeAll(() => {
|
|
44
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
for (const host of mounted.splice(0)) {
|
|
49
|
+
render(null, host);
|
|
50
|
+
host.remove();
|
|
51
|
+
}
|
|
52
|
+
(globalThis as unknown as Record<string, unknown>).fetch = nativeFetch;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
restoreDomGlobals();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mount, with the first render FLUSHED. Preact batches a state update into a
|
|
61
|
+
* microtask, so a `useEffect` that seeds a field (the dialog's generated branch)
|
|
62
|
+
* has not run when `render` returns — reading the inputs then reads empty ones,
|
|
63
|
+
* which is a property of the test harness and not of the dialog.
|
|
64
|
+
*/
|
|
65
|
+
async function mount(node: h.JSX.Element): Promise<HTMLElement> {
|
|
66
|
+
const host = document.createElement('div');
|
|
67
|
+
document.body.appendChild(host);
|
|
68
|
+
await act(async () => {
|
|
69
|
+
render(node, host);
|
|
70
|
+
});
|
|
71
|
+
mounted.push(host);
|
|
72
|
+
return host;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The picker reads the worktree list on open; answer it without a server. */
|
|
76
|
+
/** Calls the picker made, so a test can tell "not read" from "read empty". */
|
|
77
|
+
let fetchCalls: string[] = [];
|
|
78
|
+
|
|
79
|
+
function stubWorktreeList(worktrees: unknown[] = []): void {
|
|
80
|
+
fetchCalls = [];
|
|
81
|
+
(globalThis as unknown as Record<string, unknown>).fetch = async (input: RequestInfo | URL) => {
|
|
82
|
+
fetchCalls.push(String(input));
|
|
83
|
+
// A plain envelope, not a real `Response`: `Response.json()` resolves on a
|
|
84
|
+
// stream tick that this harness has no reason to wait for, and the picker
|
|
85
|
+
// only reads `ok` and `json()`.
|
|
86
|
+
const payload = { worktrees, base: '/data/worktrees/x', isRepo: true };
|
|
87
|
+
return { ok: true, json: async () => payload } as unknown as Response;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Collapsed whitespace: the markup carries newlines the reader never sees. */
|
|
92
|
+
function textOf(host: HTMLElement): string {
|
|
93
|
+
return (host.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('targetLabel', () => {
|
|
97
|
+
test('the folder alone when the chat runs in the checkout', () => {
|
|
98
|
+
expect(targetLabel([folder], 1, null)).toBe('kabmo');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('folder · worktree when a worktree is picked', () => {
|
|
102
|
+
expect(targetLabel([folder], 1, { folderId: 1, worktreePath: '/data/worktrees/abc/cosmic-dolphin' })).toBe(
|
|
103
|
+
'kabmo · cosmic-dolphin',
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a Windows worktree path names the worktree, not the whole path', () => {
|
|
108
|
+
// The label was `split('/').pop()`, which has nothing to cut in a
|
|
109
|
+
// `\`-separated path: the trigger read `kabmo · C:\Users\…\cosmic-dolphin`
|
|
110
|
+
// on a Windows runner while every POSIX leg passed.
|
|
111
|
+
expect(
|
|
112
|
+
targetLabel([folder], 1, {
|
|
113
|
+
folderId: 1,
|
|
114
|
+
worktreePath: 'C:\\Users\\u\\AppData\\Local\\Temp\\kabmo\\worktrees\\abc\\cosmic-dolphin',
|
|
115
|
+
}),
|
|
116
|
+
).toBe('kabmo · cosmic-dolphin');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('an unresolved folder still says what it is waiting for', () => {
|
|
120
|
+
expect(targetLabel([], null, null)).toBe('Select Workspace Context');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('TargetPicker', () => {
|
|
125
|
+
test('the menu offers the project root, both create paths, and the worktree list', async () => {
|
|
126
|
+
stubWorktreeList([
|
|
127
|
+
{ path: '/data/worktrees/x/old-one', branch: 'wt/old-one', detached: false, missing: false, isPrimary: false, dirty: 0, aheadOfStart: 0, startRef: 'HEAD', sessions: 0 },
|
|
128
|
+
]);
|
|
129
|
+
const host = await mount(
|
|
130
|
+
h(TargetPicker, {
|
|
131
|
+
folders: [folder],
|
|
132
|
+
selectedFolderId: 1,
|
|
133
|
+
setSelectedFolderId: () => {},
|
|
134
|
+
target: null,
|
|
135
|
+
setTarget: () => {},
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const trigger = host.querySelector('button');
|
|
140
|
+
expect(trigger?.textContent).toContain('kabmo');
|
|
141
|
+
await act(async () => {
|
|
142
|
+
(trigger as HTMLButtonElement).click();
|
|
143
|
+
});
|
|
144
|
+
// The list is read when the menu opens; each empty `act` drains one round of
|
|
145
|
+
// the fetch's promise chain, the same way the stash section's suite does.
|
|
146
|
+
for (let round = 0; round < 10; round += 1) await act(async () => {});
|
|
147
|
+
|
|
148
|
+
const text = textOf(host);
|
|
149
|
+
expect(text).toContain('Project root');
|
|
150
|
+
expect(text).toContain('Quick worktree');
|
|
151
|
+
expect(text).toContain('New worktree…');
|
|
152
|
+
expect(text).toContain('Worktrees');
|
|
153
|
+
// Creating sits above the LIST: a workspace with many worktrees must not
|
|
154
|
+
// push the action that makes one out of sight. Compared against a listed
|
|
155
|
+
// branch, because the group heading is also spelled "Worktrees".
|
|
156
|
+
expect(fetchCalls.length).toBeGreaterThan(0);
|
|
157
|
+
expect(text).toContain('wt/old-one');
|
|
158
|
+
expect(text.indexOf('Quick worktree')).toBeLessThan(text.indexOf('wt/old-one'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('the trigger shows the picked worktree, not just the folder', async () => {
|
|
162
|
+
const host = await mount(
|
|
163
|
+
h(TargetPicker, {
|
|
164
|
+
folders: [folder],
|
|
165
|
+
selectedFolderId: 1,
|
|
166
|
+
setSelectedFolderId: () => {},
|
|
167
|
+
target: { folderId: 1, worktreePath: '/data/worktrees/abc/cosmic-dolphin' },
|
|
168
|
+
setTarget: () => {},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
expect(host.querySelector('button')?.textContent).toContain('kabmo · cosmic-dolphin');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('NewWorktreeModal', () => {
|
|
176
|
+
test('opens on a generated branch, with the folder following it', async () => {
|
|
177
|
+
const host = await mount(
|
|
178
|
+
h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const inputs = [...host.querySelectorAll('input')] as HTMLInputElement[];
|
|
182
|
+
const branch = inputs[0]?.value ?? '';
|
|
183
|
+
const folderName = inputs[2]?.value ?? '';
|
|
184
|
+
expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
|
|
185
|
+
// The folder is the branch without our namespace, so it is never `wt-x`.
|
|
186
|
+
expect(folderName).toBe(branch.replace(/^wt\//, ''));
|
|
187
|
+
expect(folderName.startsWith('wt-')).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the generated branch is in the FIRST paint, before any effect flushes', () => {
|
|
191
|
+
const host = document.createElement('div');
|
|
192
|
+
document.body.appendChild(host);
|
|
193
|
+
// Deliberately NOT `mount`: no `act`, so no effect has run. A field seeded
|
|
194
|
+
// by an effect reads empty here, and the browser's own window between paint
|
|
195
|
+
// and the effect flush is exactly when a user types into it — measured: the
|
|
196
|
+
// branch filled as `main` was replaced by the generated name, and the dialog
|
|
197
|
+
// created `wt/calm-iguana` instead of reporting that `main` is taken.
|
|
198
|
+
render(
|
|
199
|
+
h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
|
|
200
|
+
host,
|
|
201
|
+
);
|
|
202
|
+
mounted.push(host);
|
|
203
|
+
|
|
204
|
+
const inputs = [...host.querySelectorAll('input')] as HTMLInputElement[];
|
|
205
|
+
const branch = inputs[0]?.value ?? '';
|
|
206
|
+
expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
|
|
207
|
+
// One generated name for both fields: deriving the folder from a SECOND
|
|
208
|
+
// call would show a folder the branch does not have.
|
|
209
|
+
expect(inputs[2]?.value ?? '').toBe(branch.replace(/^wt\//, ''));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('states what happens to uncommitted changes, and on which project', async () => {
|
|
213
|
+
const host = await mount(
|
|
214
|
+
h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
|
|
215
|
+
);
|
|
216
|
+
const text = textOf(host);
|
|
217
|
+
expect(text).toContain('Uncommitted changes stay in');
|
|
218
|
+
expect(text).toContain('kabmo');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('renders nothing while closed', async () => {
|
|
222
|
+
const host = await mount(
|
|
223
|
+
h(NewWorktreeModal, { isOpen: false, onClose: () => {}, root: '/repo', folderId: 1, onCreated: () => {} }),
|
|
224
|
+
);
|
|
225
|
+
expect(host.textContent).toBe('');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a chat that has not started will run: the workspace, or one of the
|
|
8
|
+
* workspace's worktrees.
|
|
9
|
+
*
|
|
10
|
+
* One control rather than two, because both answers are the same question. The
|
|
11
|
+
* menu is grouped exactly as OpenChamber's draft target is — the project root
|
|
12
|
+
* first, then the two ways to make a worktree, then the worktrees that already
|
|
13
|
+
* exist — and creating one here means the user does not have to leave the draft
|
|
14
|
+
* to get it.
|
|
15
|
+
*
|
|
16
|
+
* The pick is `{folderId, worktreePath}`: choosing a folder CLEARS the worktree
|
|
17
|
+
* (the groups are mutually exclusive), while choosing a worktree keeps its
|
|
18
|
+
* folder, so the row the composer draws can always name both.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
22
|
+
import { Check, ChevronDown, Folder, GitBranch } from 'lucide-preact';
|
|
23
|
+
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
24
|
+
import { useWorktrees } from '@/client/hooks/workspace/worktree/index';
|
|
25
|
+
import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
|
|
26
|
+
import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
|
|
27
|
+
import { createWorktree } from '@/client/hooks/workspace/worktree/index';
|
|
28
|
+
import { lastPathSegment } from '@/shared/lib/fs/paths';
|
|
29
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
30
|
+
|
|
31
|
+
export interface TargetPickerProps {
|
|
32
|
+
folders: WorkspaceFolderData[];
|
|
33
|
+
selectedFolderId: number | null;
|
|
34
|
+
setSelectedFolderId: (id: number | null) => void;
|
|
35
|
+
target: SpawnTarget | null;
|
|
36
|
+
setTarget: (next: SpawnTarget | null) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Reports a completed create/removal to the caller (a toast). Optional: the
|
|
39
|
+
* picker owns the flows, so a caller that only wants the pick needs nothing.
|
|
40
|
+
*/
|
|
41
|
+
onReport?: (message: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The trigger's words: the folder, plus the worktree when one is picked. */
|
|
45
|
+
export function targetLabel(
|
|
46
|
+
folders: WorkspaceFolderData[],
|
|
47
|
+
selectedFolderId: number | null,
|
|
48
|
+
target: SpawnTarget | null,
|
|
49
|
+
): string {
|
|
50
|
+
const folder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId));
|
|
51
|
+
const name = folder?.name ?? 'Select Workspace Context';
|
|
52
|
+
const worktree = lastPathSegment(target?.worktreePath);
|
|
53
|
+
return worktree ? `${name} · ${worktree}` : name;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function TargetPicker({
|
|
57
|
+
folders,
|
|
58
|
+
selectedFolderId,
|
|
59
|
+
setSelectedFolderId,
|
|
60
|
+
target,
|
|
61
|
+
setTarget,
|
|
62
|
+
onReport,
|
|
63
|
+
}: TargetPickerProps) {
|
|
64
|
+
const [open, setOpen] = useState(false);
|
|
65
|
+
const [creating, setCreating] = useState(false);
|
|
66
|
+
const [dialogFolder, setDialogFolder] = useState<WorkspaceFolderData | null>(null);
|
|
67
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
68
|
+
useOnClickOutside(containerRef, () => setOpen(false));
|
|
69
|
+
|
|
70
|
+
const selectedFolder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId)) ?? null;
|
|
71
|
+
const scopeRoot = selectedFolder?.project_path ?? null;
|
|
72
|
+
|
|
73
|
+
// The list is read only while the menu is open: it costs a `git status` per
|
|
74
|
+
// worktree, and a closed picker must never pay for it.
|
|
75
|
+
const { worktrees, error, refresh } = useWorktrees(
|
|
76
|
+
{ root: scopeRoot, folderId: selectedFolder?.id ?? null },
|
|
77
|
+
open && Boolean(scopeRoot),
|
|
78
|
+
);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (open) void refresh();
|
|
81
|
+
}, [open, refresh]);
|
|
82
|
+
|
|
83
|
+
const list = useMemo(() => worktrees.filter((worktree) => !worktree.isPrimary), [worktrees]);
|
|
84
|
+
|
|
85
|
+
const pickFolder = (folder: WorkspaceFolderData) => {
|
|
86
|
+
setSelectedFolderId(folder.id);
|
|
87
|
+
// A folder and a worktree are alternative answers, not a pair.
|
|
88
|
+
setTarget(null);
|
|
89
|
+
setOpen(false);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const pickWorktree = (folder: WorkspaceFolderData, worktreePath: string) => {
|
|
93
|
+
setSelectedFolderId(folder.id);
|
|
94
|
+
setTarget({ folderId: Number(folder.id), worktreePath });
|
|
95
|
+
setOpen(false);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Quick worktree: a generated branch and folder, created and selected without
|
|
100
|
+
* leaving the draft. Reported rather than silent — the create takes a moment
|
|
101
|
+
* and a pick that changed with no word reads as a mis-click.
|
|
102
|
+
*/
|
|
103
|
+
const quickWorktree = async (folder: WorkspaceFolderData) => {
|
|
104
|
+
if (!folder.project_path) return;
|
|
105
|
+
setCreating(true);
|
|
106
|
+
try {
|
|
107
|
+
const created = await createWorktree(
|
|
108
|
+
{ root: folder.project_path, folderId: folder.id },
|
|
109
|
+
{ mode: 'new' },
|
|
110
|
+
);
|
|
111
|
+
pickWorktree(folder, created.path);
|
|
112
|
+
onReport?.(`Worktree ${created.branch || lastPathSegment(created.path)} created`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
onReport?.(error instanceof Error ? error.message : String(error));
|
|
115
|
+
} finally {
|
|
116
|
+
setCreating(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="relative shrink-0" ref={containerRef}>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => setOpen((value) => !value)}
|
|
125
|
+
aria-haspopup="menu"
|
|
126
|
+
aria-expanded={open}
|
|
127
|
+
className="group flex items-center space-x-1.5 px-1.5 py-1 rounded-md text-xs font-medium text-ink/75 hover:text-ink hover:bg-ink/5 focus-visible:bg-ink/5 transition-all outline-none cursor-pointer select-none"
|
|
128
|
+
>
|
|
129
|
+
{target?.worktreePath ? (
|
|
130
|
+
<GitBranch size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
|
|
131
|
+
) : (
|
|
132
|
+
<Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
|
|
133
|
+
)}
|
|
134
|
+
<span className="flex items-center space-x-1 max-w-[260px] truncate tracking-tight">
|
|
135
|
+
<span>{targetLabel(folders, selectedFolderId, target)}</span>
|
|
136
|
+
</span>
|
|
137
|
+
<ChevronDown
|
|
138
|
+
size={13}
|
|
139
|
+
className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${open ? 'rotate-180 text-ink/70' : ''}`}
|
|
140
|
+
/>
|
|
141
|
+
</button>
|
|
142
|
+
|
|
143
|
+
{open && (
|
|
144
|
+
<div
|
|
145
|
+
role="menu"
|
|
146
|
+
className="absolute bottom-full left-0 mb-1.5 w-72 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1"
|
|
147
|
+
>
|
|
148
|
+
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
|
|
149
|
+
Project root
|
|
150
|
+
</div>
|
|
151
|
+
<div className="max-h-56 scrollbar-overlay-container py-1">
|
|
152
|
+
{folders.length === 0 ? (
|
|
153
|
+
<div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
|
|
154
|
+
) : (
|
|
155
|
+
folders.map((folder) => (
|
|
156
|
+
<button
|
|
157
|
+
key={folder.id}
|
|
158
|
+
type="button"
|
|
159
|
+
role="menuitemradio"
|
|
160
|
+
aria-checked={selectedFolderId === folder.id && !target?.worktreePath}
|
|
161
|
+
onClick={() => pickFolder(folder)}
|
|
162
|
+
className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
|
|
163
|
+
selectedFolderId === folder.id && !target?.worktreePath
|
|
164
|
+
? 'bg-ink/10 text-ink font-semibold'
|
|
165
|
+
: 'text-ink/80 hover:bg-ink/5 hover:text-ink'
|
|
166
|
+
}`}
|
|
167
|
+
>
|
|
168
|
+
<span className="flex items-center space-x-2 truncate pr-2">
|
|
169
|
+
<Folder size={12} className={selectedFolderId === folder.id ? 'text-ink flex-shrink-0' : 'text-ink/50 flex-shrink-0'} />
|
|
170
|
+
<span className="truncate font-medium">{folder.name}</span>
|
|
171
|
+
</span>
|
|
172
|
+
{selectedFolderId === folder.id && !target?.worktreePath && <Check size={12} className="text-ink flex-shrink-0" />}
|
|
173
|
+
</button>
|
|
174
|
+
))
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Making one comes before the list, so a long list never hides it. */}
|
|
179
|
+
{selectedFolder && scopeRoot && (
|
|
180
|
+
<>
|
|
181
|
+
<div className="border-t border-ink/5 px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold">
|
|
182
|
+
Worktrees
|
|
183
|
+
</div>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
role="menuitem"
|
|
187
|
+
disabled={creating}
|
|
188
|
+
onClick={() => void quickWorktree(selectedFolder)}
|
|
189
|
+
className="w-full text-left px-3 py-1.5 flex items-center gap-2 text-ink/80 hover:bg-ink/5 hover:text-ink disabled:opacity-50"
|
|
190
|
+
>
|
|
191
|
+
<span aria-hidden="true">⚡</span>
|
|
192
|
+
{creating ? 'Creating…' : 'Quick worktree'}
|
|
193
|
+
</button>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
role="menuitem"
|
|
197
|
+
onClick={() => {
|
|
198
|
+
setOpen(false);
|
|
199
|
+
setDialogFolder(selectedFolder);
|
|
200
|
+
}}
|
|
201
|
+
className="w-full text-left px-3 py-1.5 flex items-center gap-2 text-ink/80 hover:bg-ink/5 hover:text-ink"
|
|
202
|
+
>
|
|
203
|
+
<GitBranch size={12} className="text-ink/50" />
|
|
204
|
+
New worktree…
|
|
205
|
+
</button>
|
|
206
|
+
<div className="max-h-56 scrollbar-overlay-container py-1">
|
|
207
|
+
{list.length === 0 ? (
|
|
208
|
+
<div className="px-3 py-2 text-ink/40 italic">
|
|
209
|
+
{error ? 'Worktrees could not be read' : 'No worktrees yet'}
|
|
210
|
+
</div>
|
|
211
|
+
) : (
|
|
212
|
+
list.map((worktree) => (
|
|
213
|
+
<button
|
|
214
|
+
key={worktree.path}
|
|
215
|
+
type="button"
|
|
216
|
+
role="menuitemradio"
|
|
217
|
+
aria-checked={target?.worktreePath === worktree.path}
|
|
218
|
+
onClick={() => pickWorktree(selectedFolder, worktree.path)}
|
|
219
|
+
title={worktree.path}
|
|
220
|
+
className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
|
|
221
|
+
target?.worktreePath === worktree.path
|
|
222
|
+
? 'bg-ink/10 text-ink font-semibold'
|
|
223
|
+
: 'text-ink/80 hover:bg-ink/5 hover:text-ink'
|
|
224
|
+
}`}
|
|
225
|
+
>
|
|
226
|
+
<span className="flex items-center gap-2 truncate pr-2">
|
|
227
|
+
<GitBranch size={12} className="text-ink/50 flex-shrink-0" />
|
|
228
|
+
<span className="truncate font-medium">{worktree.branch || 'detached'}</span>
|
|
229
|
+
{worktree.missing && <span className="text-warning shrink-0">missing</span>}
|
|
230
|
+
{worktree.dirty > 0 && (
|
|
231
|
+
<span className="text-ink/40 shrink-0" title={`${worktree.dirty} uncommitted files`}>
|
|
232
|
+
●{worktree.dirty}
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
</span>
|
|
236
|
+
{target?.worktreePath === worktree.path && <Check size={12} className="text-ink flex-shrink-0" />}
|
|
237
|
+
</button>
|
|
238
|
+
))
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
<NewWorktreeModal
|
|
247
|
+
isOpen={dialogFolder !== null}
|
|
248
|
+
onClose={() => setDialogFolder(null)}
|
|
249
|
+
root={dialogFolder?.project_path ?? null}
|
|
250
|
+
folderId={dialogFolder ? Number(dialogFolder.id) : null}
|
|
251
|
+
folderName={dialogFolder?.name}
|
|
252
|
+
onCreated={(created) => {
|
|
253
|
+
if (!dialogFolder) return;
|
|
254
|
+
pickWorktree(dialogFolder, created.path);
|
|
255
|
+
onReport?.(`Worktree ${created.branch} created`);
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `eval` card body.
|
|
8
|
+
*
|
|
9
|
+
* The alignment defect is pinned here, and it was reported from a live
|
|
10
|
+
* transcript rather than found by reading the code: **the gutter numbers
|
|
11
|
+
* drifted off the code.** The gutter row was `text-[10px]` while the code was
|
|
12
|
+
* `text-[11px]`, and both inherited `leading-relaxed` — a ratio, so the row box
|
|
13
|
+
* was 16.25px against the code's 17.875px and every number sat higher than its
|
|
14
|
+
* line by a growing amount (measured after a reload: line 12's number was a
|
|
15
|
+
* full line above its code). The two sides now share one explicit line box,
|
|
16
|
+
* which is what the alignment case asserts — by comparing the two elements' own
|
|
17
|
+
* leading class, so a future edit that changes one side is caught instead of
|
|
18
|
+
* copied.
|
|
19
|
+
*
|
|
20
|
+
* The result cases cover the OTHER half of the same report ("output does not
|
|
21
|
+
* render while streaming"): the panel does read `tool.output` as it arrives, so
|
|
22
|
+
* what made a live eval look empty was the card being folded — pinned in
|
|
23
|
+
* `hooks/chat/timeline/tool-open.test.ts`, where a running eval opens itself.
|
|
24
|
+
*
|
|
25
|
+
* Rendered with `h()` against happy-dom, following `panels/Read.test.ts`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
29
|
+
import { Window } from 'happy-dom';
|
|
30
|
+
import { h, render } from 'preact';
|
|
31
|
+
import { act } from 'preact/test-utils';
|
|
32
|
+
import { Eval } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Eval';
|
|
33
|
+
import type { ToolCallData } from '@/shared/types';
|
|
34
|
+
|
|
35
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
36
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
37
|
+
|
|
38
|
+
let container: HTMLElement;
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
const win = new Window({ url: 'http://localhost' });
|
|
42
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
43
|
+
for (const key of DOM_GLOBALS) {
|
|
44
|
+
nativeGlobals[key] = target[key];
|
|
45
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
51
|
+
for (const key of DOM_GLOBALS) {
|
|
52
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
53
|
+
else target[key] = nativeGlobals[key];
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
container = document.createElement('div');
|
|
59
|
+
document.body.appendChild(container);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function mount(tool: ToolCallData): HTMLElement {
|
|
63
|
+
act(() => {
|
|
64
|
+
render(h(Eval, { tool }), container);
|
|
65
|
+
});
|
|
66
|
+
return container;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CODE = ['const a = 1;', 'const b = 2;', 'console.log(a + b);'].join('\n');
|
|
70
|
+
|
|
71
|
+
function evalTool(overrides: Partial<ToolCallData> = {}): ToolCallData {
|
|
72
|
+
return {
|
|
73
|
+
id: 'call_1',
|
|
74
|
+
type: 'eval',
|
|
75
|
+
name: 'eval',
|
|
76
|
+
title: 'Sum two numbers',
|
|
77
|
+
input: { language: 'js', code: CODE },
|
|
78
|
+
status: 'running',
|
|
79
|
+
...overrides,
|
|
80
|
+
} as ToolCallData;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `leading-[…]` token a node carries, or null. */
|
|
84
|
+
function leadingOf(el: Element): string | null {
|
|
85
|
+
return /(?:^|\s)(leading-\[\d+px\])/.exec(el.className)?.[1] ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The code card: the gutter/pre pair whose pre holds the SCRIPT. */
|
|
89
|
+
function codeCard(root: HTMLElement): { gutter: Element; pre: Element } {
|
|
90
|
+
const gutter = root.querySelectorAll('.sticky')[0];
|
|
91
|
+
const pre = gutter?.parentElement?.querySelector('pre');
|
|
92
|
+
if (!gutter || !pre) throw new Error('code card not rendered');
|
|
93
|
+
return { gutter, pre };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('Eval — result rendering', () => {
|
|
97
|
+
test('a running call renders the output it has so far', () => {
|
|
98
|
+
// omp's `tool_execution_update` carries the cell's output SO FAR, and the
|
|
99
|
+
// panel reads it from `tool.output` — which is what the fold writes from
|
|
100
|
+
// the frame's `content`. Verified against a real omp run: `content` and
|
|
101
|
+
// `details.cells[].output` are byte-identical on every update frame.
|
|
102
|
+
const root = mount(evalTool({ output: 'live 1\nlive 2' }));
|
|
103
|
+
const text = root.textContent ?? '';
|
|
104
|
+
expect(text).toContain('live 1');
|
|
105
|
+
expect(text).toContain('live 2');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('renders only the script while a running call has produced nothing yet', () => {
|
|
109
|
+
const root = mount(evalTool({ output: '' }));
|
|
110
|
+
expect(root.querySelectorAll('pre')).toHaveLength(1);
|
|
111
|
+
expect(root.textContent).toContain('Script Evaluation');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('Eval — gutter aligns with the code', () => {
|
|
116
|
+
test('the gutter and the code share one line box', () => {
|
|
117
|
+
const { gutter, pre } = codeCard(mount(evalTool()));
|
|
118
|
+
const gutterLeading = leadingOf(gutter);
|
|
119
|
+
expect(gutterLeading).not.toBeNull();
|
|
120
|
+
// The bug: an inherited RATIO (`leading-relaxed`) resolved against two
|
|
121
|
+
// different font sizes, so the boxes differed by 1.625px per line.
|
|
122
|
+
expect(leadingOf(pre)).toBe(gutterLeading);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('one gutter row per code line', () => {
|
|
126
|
+
const { gutter } = codeCard(mount(evalTool()));
|
|
127
|
+
expect(gutter.children).toHaveLength(CODE.split('\n').length);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the JSON result block aligns the same way', () => {
|
|
131
|
+
// The pretty-printed result draws its own gutter, and it had the same
|
|
132
|
+
// mismatch (plus a `py-2` against the code's `p-2.5`).
|
|
133
|
+
const root = mount(evalTool({ output: '{"a": 1, "b": [2, 3]}', status: 'success' }));
|
|
134
|
+
const gutters = root.querySelectorAll('.sticky');
|
|
135
|
+
const jsonGutter = gutters[1];
|
|
136
|
+
expect(jsonGutter).toBeDefined();
|
|
137
|
+
const pre = jsonGutter.parentElement?.querySelector('pre');
|
|
138
|
+
expect(pre).not.toBeNull();
|
|
139
|
+
// Explicit on BOTH, not merely equal: `null === null` would pass while both
|
|
140
|
+
// sides inherit a ratio against different font sizes, which is the bug.
|
|
141
|
+
expect(leadingOf(jsonGutter)).not.toBeNull();
|
|
142
|
+
expect(leadingOf(pre as Element)).toBe(leadingOf(jsonGutter));
|
|
143
|
+
// `{"a": 1, "b": [2, 3]}` pretty-prints to 7 lines.
|
|
144
|
+
expect(jsonGutter.children).toHaveLength(7);
|
|
145
|
+
});
|
|
146
|
+
});
|