@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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import { RefreshCw, Search } from 'lucide-preact';
|
|
3
2
|
import type { FsNode } from '@/shared/types';
|
|
4
|
-
import {
|
|
3
|
+
import { FileExplorerToolbar } from '@/client/components/workspace/file-explorer/Toolbar';
|
|
5
4
|
import { FileTreeItem } from '@/client/components/workspace/file-explorer/TreeItem';
|
|
6
5
|
import { RootContextMenu } from '@/client/components/workspace/file-explorer/RootMenu';
|
|
7
6
|
import { FileCreateModal } from '@/client/components/workspace/file-explorer/Modals';
|
|
@@ -11,9 +10,12 @@ import { useGitStatus } from '@/client/hooks/workspace/git-status';
|
|
|
11
10
|
import { useFileListing } from '@/client/hooks/workspace/file-listing';
|
|
12
11
|
import { useFileCreate } from '@/client/hooks/workspace/file-create';
|
|
13
12
|
import { useFileClipboard, type ClipboardTarget } from '@/client/hooks/workspace/file-clipboard';
|
|
13
|
+
import { rekeyExpansion } from '@/shared/lib/fs/file-tree';
|
|
14
|
+
import { filterFileTree } from '@/client/components/workspace/file-explorer/filter';
|
|
14
15
|
import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
|
|
15
16
|
import { useRealtimeTopic } from '@/client/hooks/ui/realtime';
|
|
16
17
|
import { useToasts } from '@/client/hooks/ui/toasts';
|
|
18
|
+
import { useTouchDevice } from '@/client/hooks/ui/touch-device';
|
|
17
19
|
import { ToastStack } from '@/client/components/common/ToastStack';
|
|
18
20
|
import { fsTopic } from '@/shared/lib/realtime/protocol';
|
|
19
21
|
|
|
@@ -98,6 +100,53 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
98
100
|
setActiveRepo(repo);
|
|
99
101
|
};
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Open every folder that leads to `path`.
|
|
105
|
+
*
|
|
106
|
+
* A nested create (`lib/parse.ts`) and a rename into a new folder both make
|
|
107
|
+
* directories the user has never opened, and a row placed inside a collapsed
|
|
108
|
+
* folder is a row nobody can see — the write would look like it failed. EVERY
|
|
109
|
+
* ancestor is opened, not only the direct parent: expanding just the last one
|
|
110
|
+
* still hides the entry behind a collapsed `lib`.
|
|
111
|
+
*/
|
|
112
|
+
const revealPath = (path: string) => {
|
|
113
|
+
const segments = path.split('/').slice(0, -1);
|
|
114
|
+
if (segments.length === 0) return;
|
|
115
|
+
const chain = segments.map((_, index) => segments.slice(0, index + 1).join('/'));
|
|
116
|
+
setExpandedPaths(prev => {
|
|
117
|
+
if (chain.every((folder) => prev.has(folder))) return prev;
|
|
118
|
+
const next = new Set(prev);
|
|
119
|
+
for (const folder of chain) next.add(folder);
|
|
120
|
+
setStoredExpandedPaths(Array.from(next));
|
|
121
|
+
return next;
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Place a row a create just produced, without re-reading the tree. */
|
|
126
|
+
const insertCreated = (entry: { path: string; kind: 'file' | 'folder' }) => {
|
|
127
|
+
listing.insertEntry(entry);
|
|
128
|
+
revealPath(entry.path);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Move a row a rename just relocated, without re-reading.
|
|
133
|
+
*
|
|
134
|
+
* A folder's descendants and every open branch beneath it carry its path as a
|
|
135
|
+
* prefix, so the expansion set is rekeyed in the same step — an open `src`
|
|
136
|
+
* renamed to `lib` must stay open as `lib`, not collapse.
|
|
137
|
+
*/
|
|
138
|
+
const moveRenamed = (fromPath: string, toPath: string) => {
|
|
139
|
+
listing.moveEntry(fromPath, toPath);
|
|
140
|
+
setExpandedPaths(prev => {
|
|
141
|
+
const next = rekeyExpansion(prev, fromPath, toPath);
|
|
142
|
+
setStoredExpandedPaths(Array.from(next));
|
|
143
|
+
return next;
|
|
144
|
+
});
|
|
145
|
+
// A rename INTO a new folder (`a.ts` → `nested/a.ts`) has the same problem a
|
|
146
|
+
// nested create does: the destination folder is one nobody has opened.
|
|
147
|
+
revealPath(toPath);
|
|
148
|
+
};
|
|
149
|
+
|
|
101
150
|
/**
|
|
102
151
|
* Re-read everything this panel shows. Defined ABOVE the `!enabled` return so
|
|
103
152
|
* the create flow below can use it: a hook cannot be called after a branch.
|
|
@@ -111,6 +160,9 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
111
160
|
// One clipboard for the whole tree, owned here and passed down: a
|
|
112
161
|
// subscription per row would be hundreds of listeners for a value that
|
|
113
162
|
// changes on a right-click.
|
|
163
|
+
// A row is a drag source; on a finger the native drag is what turned a long
|
|
164
|
+
// press into a text selection of the whole panel (see `useLongPress`).
|
|
165
|
+
const touch = useTouchDevice();
|
|
114
166
|
const { toasts, pushToast, dismissToast } = useToasts();
|
|
115
167
|
const clipboard = useFileClipboard({
|
|
116
168
|
rootPath,
|
|
@@ -155,7 +207,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
155
207
|
folderPath: '.',
|
|
156
208
|
rootPath,
|
|
157
209
|
repo: activeRepo,
|
|
158
|
-
onCreated:
|
|
210
|
+
onCreated: insertCreated,
|
|
159
211
|
onOpenCreated: (file) => onOpenFile?.({ ...file, root: rootPath, repo: activeRepo }),
|
|
160
212
|
});
|
|
161
213
|
/** Portals need a real document; the first client render is the gate. */
|
|
@@ -172,34 +224,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
172
224
|
|
|
173
225
|
const tree = listing.files;
|
|
174
226
|
|
|
175
|
-
const
|
|
176
|
-
if (!searchQuery.trim()) return tree;
|
|
177
|
-
|
|
178
|
-
const lowerQuery = searchQuery.toLowerCase();
|
|
179
|
-
|
|
180
|
-
const filterNode = (node: any): any => {
|
|
181
|
-
const isMatch = node.name.toLowerCase().includes(lowerQuery);
|
|
182
|
-
|
|
183
|
-
if (node.type === 'folder') {
|
|
184
|
-
const filteredChildren = (node.children || []).map(filterNode).filter(Boolean);
|
|
185
|
-
|
|
186
|
-
if (isMatch || filteredChildren.length > 0) {
|
|
187
|
-
return {
|
|
188
|
-
...node,
|
|
189
|
-
children: isMatch && filteredChildren.length === 0 ? node.children : filteredChildren,
|
|
190
|
-
forceExpanded: true
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return isMatch ? node : null;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
return tree.map(filterNode).filter(Boolean);
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const files = getFilteredFiles();
|
|
227
|
+
const files = filterFileTree(tree, searchQuery);
|
|
203
228
|
// The button spins for this panel's own read OR a topic push: the server
|
|
204
229
|
// republishes `fs:` on every tool call, so the tree moved with nothing on
|
|
205
230
|
// screen saying so otherwise.
|
|
@@ -207,40 +232,23 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
207
232
|
|
|
208
233
|
return (
|
|
209
234
|
<div className={`flex flex-col h-full bg-paper ${className}`}>
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
className="p-1.5 text-ink/40 hover:text-ink hover:bg-ink/5 rounded transition-colors"
|
|
223
|
-
title="Refresh"
|
|
224
|
-
>
|
|
225
|
-
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
|
|
226
|
-
</button>
|
|
227
|
-
</div>
|
|
228
|
-
<div className="relative">
|
|
229
|
-
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink/40" />
|
|
230
|
-
<input
|
|
231
|
-
type="text"
|
|
232
|
-
placeholder="Search files..."
|
|
233
|
-
value={searchQuery}
|
|
234
|
-
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
|
235
|
-
className="w-full bg-canvas border border-ink/20 rounded pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:border-ink transition-colors text-ink placeholder-ink/40"
|
|
236
|
-
/>
|
|
237
|
-
</div>
|
|
238
|
-
</div>
|
|
235
|
+
<FileExplorerToolbar
|
|
236
|
+
rootPath={rootPath}
|
|
237
|
+
activeRepo={activeRepo}
|
|
238
|
+
repos={repos}
|
|
239
|
+
reposScanning={reposScanning}
|
|
240
|
+
onRefreshRepos={rescanRepos}
|
|
241
|
+
onSelectRepo={handleSelectRepo}
|
|
242
|
+
searchQuery={searchQuery}
|
|
243
|
+
onSearchChange={setSearchQuery}
|
|
244
|
+
onRefresh={refresh}
|
|
245
|
+
refreshing={isRefreshing}
|
|
246
|
+
/>
|
|
239
247
|
|
|
240
248
|
<div
|
|
241
249
|
className={`flex-1 scrollbar-overlay-container p-2 font-mono text-[11px] text-ink/80 ${
|
|
242
|
-
|
|
243
|
-
}`}
|
|
250
|
+
touch ? 'file-tree-touch' : ''
|
|
251
|
+
} ${rootAcceptsDrop ? 'bg-info/[0.06]' : ''}`}
|
|
244
252
|
onContextMenu={(e) => {
|
|
245
253
|
// The panel's own background. A row stops propagation before this
|
|
246
254
|
// runs, so this handler only ever sees a right-click that named NO
|
|
@@ -273,7 +281,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
273
281
|
) : (
|
|
274
282
|
files.map(file => (
|
|
275
283
|
<FileTreeItem
|
|
276
|
-
key={file.
|
|
284
|
+
key={file.path}
|
|
277
285
|
file={file}
|
|
278
286
|
rootPath={rootPath}
|
|
279
287
|
basePath={listing.root}
|
|
@@ -281,6 +289,8 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
281
289
|
onLoadChildren={loadChildren}
|
|
282
290
|
onOpenFile={onOpenFile}
|
|
283
291
|
onActionComplete={refresh}
|
|
292
|
+
onCreated={insertCreated}
|
|
293
|
+
onRenamed={moveRenamed}
|
|
284
294
|
expandedPaths={expandedPaths}
|
|
285
295
|
onToggleFolder={handleToggleFolder}
|
|
286
296
|
gitFileMap={gitFileMap}
|
|
@@ -293,6 +303,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
293
303
|
if (!dragging) return;
|
|
294
304
|
clipboard.move(dragging, target);
|
|
295
305
|
}}
|
|
306
|
+
touch={touch}
|
|
296
307
|
/>
|
|
297
308
|
))
|
|
298
309
|
)}
|
|
@@ -312,7 +323,8 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
312
323
|
busy={clipboard.busy}
|
|
313
324
|
onAction={(action) => {
|
|
314
325
|
setRootMenu(null);
|
|
315
|
-
if (action === 'new_file') rootCreate.openDialog();
|
|
326
|
+
if (action === 'new_file') rootCreate.openDialog('file');
|
|
327
|
+
if (action === 'new_folder') rootCreate.openDialog('folder');
|
|
316
328
|
if (action === 'paste') clipboard.paste(rootTarget);
|
|
317
329
|
}}
|
|
318
330
|
/>
|
|
@@ -321,6 +333,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
|
|
|
321
333
|
{mounted && rootCreate.open && (
|
|
322
334
|
<FileCreateModal
|
|
323
335
|
folderPath="."
|
|
336
|
+
kind={rootCreate.kind}
|
|
324
337
|
value={rootCreate.name}
|
|
325
338
|
error={rootCreate.error}
|
|
326
339
|
isLoading={rootCreate.busy}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Files panel's local tree edits: a create and a rename land WITHOUT a
|
|
8
|
+
* reload.
|
|
9
|
+
*
|
|
10
|
+
* Both used to answer a mutation by re-reading the root plus every open folder,
|
|
11
|
+
* which is one request per open folder to learn something the response already
|
|
12
|
+
* names — and it also collapsed nothing but cost a visible spinner. The two
|
|
13
|
+
* specs here assert the two halves that matter: the row appears (or moves) in
|
|
14
|
+
* the rendered tree, and NO `/api/fs/dir` read is issued for the root to make
|
|
15
|
+
* that happen.
|
|
16
|
+
*
|
|
17
|
+
* A nested create is the third case, because it is the one a naive insert gets
|
|
18
|
+
* wrong: the folders leading to the entry do not exist in the tree yet, and a
|
|
19
|
+
* row placed under a folder nobody has opened is a row the user cannot see —
|
|
20
|
+
* so the create also opens the chain it created.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
|
+
import { createFileExplorerHarness } from '@/test-support/file-explorer.test-util';
|
|
25
|
+
|
|
26
|
+
const panel = createFileExplorerHarness();
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
panel.install();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
panel.unmount();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
panel.restore();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The root's own reads, which a reload would add one of. */
|
|
41
|
+
function rootReads(): number {
|
|
42
|
+
return panel.dirReads.filter((path) => path === '.').length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('Files panel local edits', () => {
|
|
46
|
+
test('a created file appears in the tree without re-reading the root', async () => {
|
|
47
|
+
panel.disk = { '.': { 'existing.ts': 'file' } };
|
|
48
|
+
panel.dirReads = [];
|
|
49
|
+
await panel.mount();
|
|
50
|
+
const before = rootReads();
|
|
51
|
+
|
|
52
|
+
await panel.openRootNewFile();
|
|
53
|
+
await panel.submitNewFile('fresh.ts');
|
|
54
|
+
|
|
55
|
+
expect(panel.buttonLabels()).not.toContain('Create');
|
|
56
|
+
expect(panel.visibleNames()).toContain('fresh.ts');
|
|
57
|
+
// The create answered with the path, so the tree placed it: a reload here
|
|
58
|
+
// would be a root read plus one per open folder.
|
|
59
|
+
expect(rootReads()).toBe(before);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a created FOLDER appears and is not opened in an editor', async () => {
|
|
63
|
+
panel.disk = { '.': {} };
|
|
64
|
+
panel.dirReads = [];
|
|
65
|
+
await panel.mount();
|
|
66
|
+
const before = rootReads();
|
|
67
|
+
|
|
68
|
+
await panel.openRootNewFolder();
|
|
69
|
+
await panel.submitNewFile('packages');
|
|
70
|
+
|
|
71
|
+
expect(panel.createRequests.at(-1)).toEqual({
|
|
72
|
+
actionType: 'create',
|
|
73
|
+
path: '.',
|
|
74
|
+
name: 'packages',
|
|
75
|
+
kind: 'folder',
|
|
76
|
+
root: '/ws',
|
|
77
|
+
});
|
|
78
|
+
expect(panel.visibleNames()).toContain('packages');
|
|
79
|
+
// A folder has nothing to open — opening one would be an empty editor tab.
|
|
80
|
+
expect(panel.openedFiles).toEqual([]);
|
|
81
|
+
expect(rootReads()).toBe(before);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a nested create opens the chain it created, so the row is visible', async () => {
|
|
85
|
+
panel.disk = { '.': {} };
|
|
86
|
+
panel.dirReads = [];
|
|
87
|
+
await panel.mount();
|
|
88
|
+
|
|
89
|
+
await panel.openRootNewFile();
|
|
90
|
+
await panel.submitNewFile('lib/deep/parse.ts');
|
|
91
|
+
|
|
92
|
+
// Every folder on the way is expanded and reads its own directory: the
|
|
93
|
+
// entry is not somewhere the user has to go hunting for.
|
|
94
|
+
expect(panel.visibleNames()).toContain('parse.ts');
|
|
95
|
+
expect(panel.visibleNames()).toContain('deep');
|
|
96
|
+
expect(panel.visibleNames()).toContain('lib');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a rename moves the row in place without re-reading the root', async () => {
|
|
100
|
+
panel.disk = { '.': { 'old.ts': 'file' } };
|
|
101
|
+
panel.dirReads = [];
|
|
102
|
+
await panel.mount();
|
|
103
|
+
const before = rootReads();
|
|
104
|
+
|
|
105
|
+
await panel.rightClickRow('old.ts');
|
|
106
|
+
await panel.clickMenuRow('Rename');
|
|
107
|
+
await panel.submitRename('new.ts');
|
|
108
|
+
|
|
109
|
+
expect(panel.renameRequests.at(-1)).toEqual({
|
|
110
|
+
actionType: 'rename',
|
|
111
|
+
path: 'old.ts',
|
|
112
|
+
newPath: 'new.ts',
|
|
113
|
+
root: '/ws',
|
|
114
|
+
});
|
|
115
|
+
expect(panel.visibleNames()).toContain('new.ts');
|
|
116
|
+
expect(panel.visibleNames()).not.toContain('old.ts');
|
|
117
|
+
expect(rootReads()).toBe(before);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a refused rename keeps the dialog open with the server’s reason', async () => {
|
|
121
|
+
panel.disk = { '.': { 'old.ts': 'file', 'taken.ts': 'file' } };
|
|
122
|
+
panel.dirReads = [];
|
|
123
|
+
await panel.mount();
|
|
124
|
+
|
|
125
|
+
await panel.rightClickRow('old.ts');
|
|
126
|
+
await panel.clickMenuRow('Rename');
|
|
127
|
+
await panel.submitRename('taken.ts');
|
|
128
|
+
|
|
129
|
+
// The field is still there: "already exists" is a message about what to
|
|
130
|
+
// type next, not a notification about something that happened.
|
|
131
|
+
expect(panel.renameInput()).not.toBeNull();
|
|
132
|
+
expect(panel.documentText()).toContain('taken.ts already exists');
|
|
133
|
+
expect(panel.visibleNames()).toContain('old.ts');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* New worktree — the dialog, modelled on OpenChamber's.
|
|
8
|
+
*
|
|
9
|
+
* Two columns on a wide screen and one on a phone, and the split is the point:
|
|
10
|
+
* the left column is the CHOICE (which branch this checkout is), the right is the
|
|
11
|
+
* NAMES (branch, base, folder). A single column put the choice above a wall of
|
|
12
|
+
* fields and the "Create worktree" button below the fold.
|
|
13
|
+
*
|
|
14
|
+
* Three rules the form carries, each from a measured failure:
|
|
15
|
+
*
|
|
16
|
+
* - **The folder follows the branch until the user edits it.** A dialog that
|
|
17
|
+
* asked for both independently produced a folder named after the previous
|
|
18
|
+
* branch the moment the branch was retyped; the reset button puts it back in
|
|
19
|
+
* sync and is disabled while it already is.
|
|
20
|
+
* - **Validation is the SERVER's.** A branch collision, a branch already checked
|
|
21
|
+
* out, an unborn repository — the client cannot know any of them without git,
|
|
22
|
+
* so the debounced call runs the same plan the create will run.
|
|
23
|
+
* - **The dirty notice states what stays behind.** Uncommitted changes are not
|
|
24
|
+
* carried into a new worktree, and a user told that afterwards has already
|
|
25
|
+
* lost the chance to commit them.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
29
|
+
import { Modal } from '@/client/components/common/Modal';
|
|
30
|
+
import { createWorktree, validateWorktree } from '@/client/hooks/workspace/worktree/index';
|
|
31
|
+
import { WorktreeRequestError } from '@/client/hooks/workspace/worktree/index';
|
|
32
|
+
import { defaultWorktreeBranch, folderNameForBranch } from '@/shared/lib/workspace/worktree/branch-name';
|
|
33
|
+
import type { WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
|
|
34
|
+
|
|
35
|
+
export interface NewWorktreeModalProps {
|
|
36
|
+
isOpen: boolean;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
/** The project the worktree belongs to. */
|
|
39
|
+
root: string | null;
|
|
40
|
+
folderId: number | null;
|
|
41
|
+
folderName?: string;
|
|
42
|
+
/** Called with the created path — the caller selects it as the chat's target. */
|
|
43
|
+
onCreated: (worktree: { path: string; branch: string }) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Mode = 'new' | 'existing';
|
|
47
|
+
|
|
48
|
+
/** A thrown request, narrowed to the failure the dialog reports. */
|
|
49
|
+
function asFailure(error: unknown): WorktreeFailure {
|
|
50
|
+
if (error instanceof WorktreeRequestError) return error.failure;
|
|
51
|
+
return { code: 'create_failed', message: error instanceof Error ? error.message : String(error) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FIELD_CLASS = 'w-full h-8 rounded-md border border-ink/15 bg-paper px-2 text-xs text-ink outline-none focus:border-ink/40';
|
|
55
|
+
|
|
56
|
+
export function NewWorktreeModal(props: NewWorktreeModalProps) {
|
|
57
|
+
// Mounted fresh on every open, never reset by an effect. Preact runs effects
|
|
58
|
+
// AFTER paint, so an effect-seeded form is on screen and focusable while its
|
|
59
|
+
// fields still hold the previous open's values — and anything typed in that
|
|
60
|
+
// window is discarded. Measured in the browser: the branch field was filled
|
|
61
|
+
// with `main`, the effect then restored the generated name, and the dialog
|
|
62
|
+
// created `wt/calm-iguana` instead of reporting that `main` is taken.
|
|
63
|
+
if (!props.isOpen) return null;
|
|
64
|
+
return <WorktreeForm {...props} />;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function WorktreeForm({ onClose, root, folderId, folderName, onCreated }: NewWorktreeModalProps) {
|
|
68
|
+
const [mode, setMode] = useState<Mode>('new');
|
|
69
|
+
// Seeded by the initial state, so the very first paint carries a usable form.
|
|
70
|
+
const [branch, setBranch] = useState(defaultWorktreeBranch);
|
|
71
|
+
const [basedOn, setBasedOn] = useState('');
|
|
72
|
+
const [folder, setFolder] = useState(() => folderNameForBranch(branch));
|
|
73
|
+
const [folderFollowsBranch, setFolderFollowsBranch] = useState(true);
|
|
74
|
+
const [failure, setFailure] = useState<WorktreeFailure | null>(null);
|
|
75
|
+
const [creating, setCreating] = useState(false);
|
|
76
|
+
|
|
77
|
+
// The folder mirrors the branch while the user has not renamed it.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!folderFollowsBranch) return;
|
|
80
|
+
setFolder(folderNameForBranch(branch));
|
|
81
|
+
}, [branch, folderFollowsBranch]);
|
|
82
|
+
|
|
83
|
+
const request = useMemo(
|
|
84
|
+
() => ({
|
|
85
|
+
mode,
|
|
86
|
+
branch: branch.trim(),
|
|
87
|
+
worktreeName: folder.trim(),
|
|
88
|
+
...(basedOn.trim() ? { startRef: basedOn.trim() } : {}),
|
|
89
|
+
}),
|
|
90
|
+
[mode, branch, folder, basedOn],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Debounced server validation, after the first edit — the answers depend on
|
|
94
|
+
// git state a keystroke cannot know.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!root) return;
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
void validateWorktree({ root, folderId }, request).then((result) => {
|
|
99
|
+
setFailure(result.ok ? null : result.failures[0] ?? null);
|
|
100
|
+
}).catch(() => {
|
|
101
|
+
// A validation request that failed says nothing about the form; the
|
|
102
|
+
// create reports what it finds.
|
|
103
|
+
});
|
|
104
|
+
}, 300);
|
|
105
|
+
return () => clearTimeout(timer);
|
|
106
|
+
}, [root, folderId, request]);
|
|
107
|
+
|
|
108
|
+
const canCreate = Boolean(root) && branch.trim().length > 0 && folder.trim().length > 0 && !creating && !failure;
|
|
109
|
+
|
|
110
|
+
const submit = async () => {
|
|
111
|
+
if (!root || !canCreate) return;
|
|
112
|
+
setCreating(true);
|
|
113
|
+
try {
|
|
114
|
+
const created = await createWorktree({ root, folderId }, request);
|
|
115
|
+
onCreated({ path: created.path, branch: created.branch });
|
|
116
|
+
onClose();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// `WorktreeRequestError` carries the server's field/code; anything else is
|
|
119
|
+
// reported as a create failure with its own message.
|
|
120
|
+
setFailure(asFailure(error));
|
|
121
|
+
} finally {
|
|
122
|
+
setCreating(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const choiceColumn = (
|
|
127
|
+
<div className="flex flex-col gap-4">
|
|
128
|
+
<div className="flex flex-col gap-2">
|
|
129
|
+
<span className="text-[11px] font-semibold text-ink">Start from</span>
|
|
130
|
+
{([
|
|
131
|
+
{ id: 'new' as const, label: 'New branch', hint: 'A fresh branch, based on the branch you pick.' },
|
|
132
|
+
{ id: 'existing' as const, label: 'Existing branch', hint: 'Check out a branch you already have.' },
|
|
133
|
+
]).map((option) => (
|
|
134
|
+
<button
|
|
135
|
+
key={option.id}
|
|
136
|
+
type="button"
|
|
137
|
+
role="radio"
|
|
138
|
+
aria-checked={mode === option.id}
|
|
139
|
+
onClick={() => setMode(option.id)}
|
|
140
|
+
className={`rounded-md border px-3 py-2 text-left transition-colors ${
|
|
141
|
+
mode === option.id ? 'border-ink/40 bg-ink/5' : 'border-ink/15 hover:bg-ink/5'
|
|
142
|
+
}`}
|
|
143
|
+
>
|
|
144
|
+
<span className="block text-xs font-medium text-ink">{option.label}</span>
|
|
145
|
+
<span className="block text-[10.5px] text-ink/50">{option.hint}</span>
|
|
146
|
+
</button>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const fieldColumn = (
|
|
153
|
+
<div className="flex flex-col gap-4">
|
|
154
|
+
<label className="flex flex-col gap-1.5">
|
|
155
|
+
<span className="text-[11px] font-semibold text-ink">{mode === 'existing' ? 'Branch' : 'Branch name'}</span>
|
|
156
|
+
<input
|
|
157
|
+
value={branch}
|
|
158
|
+
onChange={(event) => setBranch(event.currentTarget.value)}
|
|
159
|
+
onKeyDown={(event) => {
|
|
160
|
+
if (event.key === 'Enter') {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
void submit();
|
|
163
|
+
}
|
|
164
|
+
}}
|
|
165
|
+
placeholder="feature/my-awesome-feature"
|
|
166
|
+
className={FIELD_CLASS}
|
|
167
|
+
aria-invalid={failure?.field === 'branch'}
|
|
168
|
+
/>
|
|
169
|
+
</label>
|
|
170
|
+
|
|
171
|
+
<label className="flex flex-col gap-1.5">
|
|
172
|
+
<span className="text-[11px] font-semibold text-ink">Based on</span>
|
|
173
|
+
<input
|
|
174
|
+
value={basedOn}
|
|
175
|
+
onChange={(event) => setBasedOn(event.currentTarget.value)}
|
|
176
|
+
placeholder="origin/main"
|
|
177
|
+
disabled={mode === 'existing'}
|
|
178
|
+
className={`${FIELD_CLASS} disabled:opacity-50`}
|
|
179
|
+
/>
|
|
180
|
+
<span className="text-[10px] text-ink/40">
|
|
181
|
+
Defaults to the repository's default branch. Left empty means its current HEAD.
|
|
182
|
+
</span>
|
|
183
|
+
</label>
|
|
184
|
+
|
|
185
|
+
<label className="flex flex-col gap-1.5">
|
|
186
|
+
<span className="text-[11px] font-semibold text-ink">Folder name</span>
|
|
187
|
+
<span className="flex items-center gap-1.5">
|
|
188
|
+
<input
|
|
189
|
+
value={folder}
|
|
190
|
+
onChange={(event) => {
|
|
191
|
+
setFolder(event.currentTarget.value);
|
|
192
|
+
setFolderFollowsBranch(false);
|
|
193
|
+
}}
|
|
194
|
+
placeholder="my-worktree-directory"
|
|
195
|
+
className={`${FIELD_CLASS} min-w-0 flex-1`}
|
|
196
|
+
aria-invalid={failure?.field === 'folder'}
|
|
197
|
+
/>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
title="Reset to match branch name"
|
|
201
|
+
aria-label="Reset to match branch name"
|
|
202
|
+
disabled={folderFollowsBranch}
|
|
203
|
+
onClick={() => {
|
|
204
|
+
setFolderFollowsBranch(true);
|
|
205
|
+
setFolder(folderNameForBranch(branch));
|
|
206
|
+
}}
|
|
207
|
+
className="h-8 shrink-0 rounded-md border border-ink/15 px-2 text-[11px] text-ink/70 hover:bg-ink/5 disabled:opacity-40"
|
|
208
|
+
>
|
|
209
|
+
⟳
|
|
210
|
+
</button>
|
|
211
|
+
</span>
|
|
212
|
+
</label>
|
|
213
|
+
|
|
214
|
+
<p className="text-[10px] text-ink/45">
|
|
215
|
+
Uncommitted changes stay in {folderName ? `“${folderName}”` : 'the project'}. A new worktree starts from a
|
|
216
|
+
commit, so nothing half-written comes with it.
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Modal
|
|
223
|
+
onClose={onClose}
|
|
224
|
+
maxWidthClass="max-w-[min(96vw,880px)]"
|
|
225
|
+
header={<h2 className="text-sm font-semibold text-ink">New worktree</h2>}
|
|
226
|
+
footer={
|
|
227
|
+
<div className="flex w-full items-center gap-2 border-t border-ink/10 px-5 py-3">
|
|
228
|
+
{/* One line that never grows the dialog; the full message is its title. */}
|
|
229
|
+
<p
|
|
230
|
+
className="min-w-0 flex-1 truncate text-[11px] text-error"
|
|
231
|
+
title={failure?.message}
|
|
232
|
+
role={failure ? 'alert' : undefined}
|
|
233
|
+
>
|
|
234
|
+
{failure?.message ?? ''}
|
|
235
|
+
</p>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
onClick={onClose}
|
|
239
|
+
disabled={creating}
|
|
240
|
+
className="h-8 shrink-0 rounded-md border border-ink/15 px-3 text-xs text-ink/80 hover:bg-ink/5"
|
|
241
|
+
>
|
|
242
|
+
Cancel
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => void submit()}
|
|
247
|
+
disabled={!canCreate}
|
|
248
|
+
className="h-8 shrink-0 rounded-md bg-ink px-3 text-xs font-medium text-paper hover:bg-ink/90 disabled:opacity-40"
|
|
249
|
+
>
|
|
250
|
+
{creating ? 'Creating…' : 'Create worktree'}
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
}
|
|
254
|
+
>
|
|
255
|
+
<div className="grid grid-cols-1 gap-6 px-5 py-4 md:grid-cols-2">
|
|
256
|
+
{choiceColumn}
|
|
257
|
+
{fieldColumn}
|
|
258
|
+
</div>
|
|
259
|
+
</Modal>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useTimelineScope } from '@/client/hooks/chat/timeline/scope';
|
|
3
|
+
import { useSpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
|
|
3
4
|
import type { ChatMessageData } from '@/shared/types';
|
|
4
5
|
import { useOmpAgent } from '@/client/hooks/chat/omp';
|
|
5
6
|
import { useChatTimelineQueue } from '@/client/hooks/chat/timeline/queue';
|
|
@@ -57,8 +58,7 @@ export function useChatTimeline({
|
|
|
57
58
|
const { inputValue, setInputValue, inputAttachments, setInputAttachments } = useComposerDraft();
|
|
58
59
|
const [localMessages, setLocalMessages] = useState<ChatMessageData[]>([]);
|
|
59
60
|
|
|
60
|
-
// A pending "new-…" session renders the workspace picker
|
|
61
|
-
// timeline and scroll handling.
|
|
61
|
+
// A pending "new-…" session renders the workspace picker with its own timeline.
|
|
62
62
|
const isPendingSession = Boolean(sessionId?.startsWith('new-'));
|
|
63
63
|
// Committed history lands asynchronously (session-load.ts), so the container
|
|
64
64
|
// paints at the top first; jump to the tail once per session when it does.
|
|
@@ -211,6 +211,8 @@ export function useChatTimeline({
|
|
|
211
211
|
enqueueExtensionDialog,
|
|
212
212
|
withdrawExtensionDialog,
|
|
213
213
|
}), activityRef);
|
|
214
|
+
const spawn = useSpawnTarget(sessionId);
|
|
215
|
+
|
|
214
216
|
const { steerOmpAgent, executeSend } = useChatTimelineSend({
|
|
215
217
|
folders,
|
|
216
218
|
selectedFolderId,
|
|
@@ -234,15 +236,15 @@ export function useChatTimeline({
|
|
|
234
236
|
deferredComposerPickRef,
|
|
235
237
|
accessModeRef,
|
|
236
238
|
spawnSelectionRef: modes.spawnSelectionRef,
|
|
239
|
+
spawnWorktreeRef: spawn.worktreePathRef,
|
|
237
240
|
abortControllerRef,
|
|
238
241
|
setInputValue,
|
|
239
242
|
setSearchParams,
|
|
240
243
|
});
|
|
241
244
|
|
|
242
245
|
// Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
|
|
243
|
-
// schedules the next queued prompt
|
|
244
|
-
//
|
|
245
|
-
// panel is a view; `stopHoldRef` remains only for the mock path below.
|
|
246
|
+
// schedules the next queued prompt, with a transactional head claim so no
|
|
247
|
+
// reload/tab race can double-send. `stopHoldRef` serves only the mock path.
|
|
246
248
|
|
|
247
249
|
const {
|
|
248
250
|
handleSend,
|
|
@@ -339,6 +341,8 @@ export function useChatTimeline({
|
|
|
339
341
|
composerModelRef,
|
|
340
342
|
deferredComposerPickRef,
|
|
341
343
|
waitingDependency,
|
|
344
|
+
spawnTarget: spawn.target,
|
|
345
|
+
setSpawnTarget: spawn.setTarget,
|
|
342
346
|
extensionDialogs,
|
|
343
347
|
resolveExtensionDialog,
|
|
344
348
|
respondToExtensionUi: ompAgent.respondToExtensionUi,
|