@1yefuwang1/dsh-worktrees 0.0.0-stage → 0.2.14
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/CHANGELOG.md +229 -0
- package/LICENSE +21 -0
- package/README.md +474 -2
- package/client.js +1425 -0
- package/cordis.patch.yml +6 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.js +70 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +30 -0
- package/dist/first-message.d.ts +19 -0
- package/dist/first-message.js +121 -0
- package/dist/git.d.ts +45 -0
- package/dist/git.js +806 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +183 -0
- package/dist/naming.d.ts +25 -0
- package/dist/naming.js +151 -0
- package/dist/project-context.d.ts +20 -0
- package/dist/project-context.js +82 -0
- package/dist/project-rpc.d.ts +10 -0
- package/dist/project-rpc.js +103 -0
- package/dist/project-store.d.ts +121 -0
- package/dist/project-store.js +32 -0
- package/dist/projects.d.ts +149 -0
- package/dist/projects.js +603 -0
- package/dist/quiet-rpc.d.ts +7 -0
- package/dist/quiet-rpc.js +125 -0
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.js +418 -0
- package/dist/runtime.d.ts +22 -0
- package/dist/runtime.js +123 -0
- package/dist/schema.d.ts +50 -0
- package/dist/schema.js +86 -0
- package/dist/service.d.ts +98 -0
- package/dist/service.js +943 -0
- package/dist/sessions.d.ts +31 -0
- package/dist/sessions.js +130 -0
- package/dist/store.d.ts +114 -0
- package/dist/store.js +44 -0
- package/dist/types.d.ts +262 -0
- package/dist/types.js +1 -0
- package/icon.svg +1 -0
- package/locale/en.json +6 -0
- package/package.json +106 -3
package/client.js
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
/* Persistent plain-JS Client half. Guarded rc.2 submit adapter; no DOM interception.
|
|
2
|
+
* Projects replace only the workspace browsing hole; native views and row
|
|
3
|
+
* extensions remain installed, with owned aliases and a Folder view fallback.
|
|
4
|
+
*/
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: '@1yefuwang1/dsh-worktrees',
|
|
7
|
+
factory(require) {
|
|
8
|
+
const React = require('react');
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
const NS = 'worktrees.ui';
|
|
11
|
+
const PANEL = 'dsh-worktrees';
|
|
12
|
+
const VIEW_SWITCH = PANEL + '-projects-view';
|
|
13
|
+
const en = {
|
|
14
|
+
searchProjects: 'Search projects and sessions', closeSearch: 'Close search', viewOptions: 'View options',
|
|
15
|
+
switchProjectsView: 'Switch to Projects View', switchWorkspaceView: 'Switch to Workspace View',
|
|
16
|
+
'time.now': 'now', 'time.minutes': '{n}min', 'time.hours': '{n}h', 'time.days': '{n}d', 'time.months': '{n}mo', 'time.years': '{n}y', 'date.ymd': '{y}-{m}-{d}', lastActive: 'Last active {time}',
|
|
17
|
+
projects: 'Projects', folderView: 'Folder view', newProject: 'New project', editProject: 'Manage project', projectName: 'Project name', project: 'Project', folders: 'Folders', folder: 'Folder', addFolder: 'Add folder', chooseDirectory: 'Choose directory', browse: 'Browse folders', absolutePath: 'Absolute Host directory path', save: 'Save', newThread: 'New thread', threadMode: 'Thread mode', threadActions: 'Thread actions', threadDetails: 'Thread details', unassigned: 'Other threads', showMore: 'Show {n} more sessions', showLess: 'Show less', filterThreads: 'Search threads or projects', allThreads: 'All threads', archivedOnly: 'Archived only', hideArchived: 'Hide archived', untitled: 'Untitled', blankThread: 'New thread', collapse: 'Collapse project', expand: 'Expand project', waiting: 'Waiting for interaction', done: 'Finished, not yet viewed', localThread: 'Local thread', worktreeThread: 'Worktree thread', draftWarning: 'This draft contains text or attachments. Keep it in this thread before choosing another project or folder.', chooseFolder: 'Choose a folder', createFolder: 'Create folder', folderName: 'Folder name', chooseThisFolder: 'Use this folder', noProjects: 'Add a project to organize your threads.',
|
|
18
|
+
repoUnavailable: 'Worktree mode is unavailable or unverified for this folder. Choose Local to continue.',
|
|
19
|
+
projectChanged: 'The project or selected folder changed. Choose Local or keep the current folder before selecting New worktree again.', keepCurrentFolder: 'Keep current folder',
|
|
20
|
+
removeProject: 'Remove project', removeProjectConfirm: 'Remove this project?', removeProjectHelp: 'Only the project metadata will be removed. All folders, files, conversations and worktrees will be kept. Conversations remain available under Other threads and in Folder view.',
|
|
21
|
+
mainFolder: 'Main folder', main: 'Main', chooseMainFolder: 'Select the main folder', mainFolderRequired: 'Choose a main folder from the project source folders.', mainFolderHelp: 'Default for new conversations. You can choose another folder before sending.', conversationFolder: 'Conversation folder',
|
|
22
|
+
createProject: 'Create project', sourceFolders: 'Source folders', localFolders: 'Add a folder on this computer', enterFolderPath: 'Enter a folder path', add: 'Add', cancel: 'Cancel', selectedFolders: 'Selected folders', selectFolder: 'Select folder', folderLimit: 'A project can contain at most 32 folders.', invalidFolder: 'Enter a valid absolute Host folder path.', alreadyAdded: 'Already added', multiFolderHelp: 'Select multiple folders using the checkboxes. Selections are kept as you browse.', hostFolderHelp: 'Folders are on the Host computer; adding them does not move files.', noSubfolders: 'No subfolders.', directoryTruncated: 'This listing is truncated. Enter a path to browse other folders.',
|
|
23
|
+
rootFolder: 'Worktree root folder', rootSummary: 'Choose where new worktrees are created.', rootHelp: 'Absolute folder path on the Host, outside your source repositories. Changes apply to new worktrees only; existing directories and in-flight creates are not moved.', rootReset: 'Reset to inherited default', rootSaved: 'Saved. New worktrees will use this location.', rootUnavailable: 'Host-backed settings are unavailable for this entry.', rootInvalid: 'Enter a valid absolute Host folder path.', rootConflict: 'The setting changed or the Host refused the write. Your draft is preserved; reopen this page to read the latest value.',
|
|
24
|
+
pinned: 'Pinned', title: 'Worktrees', local: 'Local', new: 'New worktree', remote: 'Remote', branch: 'Remote base branch',
|
|
25
|
+
setupProgress: 'Preparing worktree', preparingMessage: 'Preparing your first message…', fetchingLatest: 'Fetching latest remote branch…', creatingWorktree: 'Creating worktree…', namingBranch: 'Generating and applying branch name…', openingConversation: 'Opening worktree conversation…', setupReady: 'Worktree ready', stagingAttachments: 'Preparing attachments for this worktree…', startingConversation: 'Starting normal conversation…', setupFailed: 'Worktree preparation failed', cancelSetup: 'Cancel setup', unsupported: 'This native composer version is not supported by the guarded adapter.', filesLost: 'A draft attachment is no longer available.', upload: 'Attachment upload failed', uncertain: 'Prompt admission is uncertain. Open the created conversation before trying again.',
|
|
26
|
+
search: 'Search remote branches', select: 'Base branch', refresh: 'Refresh', more: 'Load more', close: 'Close', open: 'Open conversation', remove: 'Remove', creatingWorktree: 'Creating worktree…',
|
|
27
|
+
createBranch: 'Create branch here', branchName: 'New branch name', protect: 'Protect', unprotect: 'Unprotect',
|
|
28
|
+
archive: 'Archive (retain files)', restore: 'Restore', archived: 'Archived', includeArchived: 'Include archived',
|
|
29
|
+
preview: 'Preview handoff', export: 'Export patch', apply: 'Apply to Local and continue',
|
|
30
|
+
previewTitle: 'Review handoff to Local', original: 'Original Local checkout', target: 'Local target path',
|
|
31
|
+
openSource: 'Open a worktree conversation above before previewing or applying its handoff.',
|
|
32
|
+
noWriters: 'I have stopped all other writers in both checkouts and will keep them stopped during handoff.',
|
|
33
|
+
handoffWarning: 'Requires Full access and idle sessions. Local must be clean at the saved base. No pull, reset or branch switch. This is not a globally atomic operation; external writers must remain stopped. Source files and the patch are retained.',
|
|
34
|
+
loading: 'Loading…', empty: 'No worktrees in this repository.', actor: 'Open a conversation before using the worktree manager.',
|
|
35
|
+
detached: 'Detached', dirty: 'Dirty', clean: 'Clean', protected: 'Protected', unprotected: 'Unprotected',
|
|
36
|
+
base: 'Base', sessions: 'Conversations', state: 'State', retained: 'Archiving retains the directory and conversations; it does not delete files.',
|
|
37
|
+
operation: 'Operation', reconcile: 'Check operation status', cancelled: 'Cancelled; any created checkout is retained.',
|
|
38
|
+
files: 'Files', binary: 'Binary', bytes: 'Bytes', patch: 'Retained patch path', copy: 'Copy path', copied: 'Path copied',
|
|
39
|
+
transport: 'Transport failure',
|
|
40
|
+
decode: 'Invalid or unsupported worktree response', host: 'Host refused the operation',
|
|
41
|
+
blocked: 'Another plugin is using this composer; its state was left unchanged.',
|
|
42
|
+
ownership: 'Composer ownership changed; creation was cancelled.', changed: 'The source, draft, settings or navigation changed; creation was cancelled.',
|
|
43
|
+
targetBusy: 'The exact destination is not an empty, idle composer; the source draft is unchanged.',
|
|
44
|
+
invalidSession: 'The Host did not return an exact destination session.',
|
|
45
|
+
unknown: 'Operation failed', statusError: 'Checkout status unavailable', noRemote: 'No advertised remote branches. Choose a remote and refresh; no cached base will be used.',
|
|
46
|
+
idle: 'Idle', busy: 'Busy', recovery: 'Recovery required', settings: 'Repository settings changed. Check the created worktree before continuing.',
|
|
47
|
+
};
|
|
48
|
+
const zh = { ...en,
|
|
49
|
+
searchProjects: '搜索项目和会话', closeSearch: '关闭搜索', viewOptions: '视图选项',
|
|
50
|
+
switchProjectsView: '切换到项目视图', switchWorkspaceView: '切换到工作区视图',
|
|
51
|
+
'time.now': '刚刚', 'time.minutes': '{n}分钟', 'time.hours': '{n}小时', 'time.days': '{n}天', 'time.months': '{n}个月', 'time.years': '{n}年', 'date.ymd': '{y}年{m}月{d}日', lastActive: '最后活跃于 {time}',
|
|
52
|
+
projects: '项目', folderView: '文件夹视图', newProject: '新建项目', editProject: '管理项目', projectName: '项目名称', project: '项目', folders: '文件夹', folder: '文件夹', addFolder: '添加文件夹', chooseDirectory: '选择目录', browse: '浏览文件夹', absolutePath: '主机绝对目录路径', save: '保存', newThread: '新建对话', threadMode: '对话模式', threadActions: '对话操作', threadDetails: '对话详情', unassigned: '其他对话', showMore: '展开其余 {n} 个会话', showLess: '收起', filterThreads: '搜索对话或项目', allThreads: '所有对话', archivedOnly: '仅已归档', hideArchived: '隐藏已归档', untitled: '未命名', blankThread: '新对话', collapse: '收起项目', expand: '展开项目', waiting: '等待交互', done: '已完成,尚未查看', localThread: '本地对话', worktreeThread: '工作树对话', draftWarning: '此草稿包含文字或附件。请先保留当前对话,再选择其他项目或文件夹。', chooseFolder: '选择文件夹', createFolder: '创建文件夹', folderName: '文件夹名称', chooseThisFolder: '使用此文件夹', noProjects: '添加项目以组织您的对话。',
|
|
53
|
+
repoUnavailable: '此文件夹的工作树模式不可用或尚未验证,请选择本地模式继续。',
|
|
54
|
+
projectChanged: '项目或选定文件夹已更改,请先选择本地模式或保留当前文件夹,再重新选择新建工作树。', keepCurrentFolder: '保留当前文件夹',
|
|
55
|
+
removeProject: '移除项目', removeProjectConfirm: '移除此项目?', removeProjectHelp: '仅移除项目元数据,所有文件夹、文件、对话和工作树都会保留。对话仍可在“其他对话”和“文件夹视图”中访问。',
|
|
56
|
+
mainFolder: '主文件夹', main: '主目录', chooseMainFolder: '选择主文件夹', mainFolderRequired: '请从项目源文件夹中选择一个主文件夹。', mainFolderHelp: '新对话默认使用此文件夹,发送前仍可选择其他文件夹。', conversationFolder: '对话文件夹',
|
|
57
|
+
createProject: '创建项目', sourceFolders: '源文件夹', localFolders: '添加此计算机上的文件夹', enterFolderPath: '输入文件夹路径', add: '添加', cancel: '取消', selectedFolders: '已选文件夹', selectFolder: '选择文件夹', folderLimit: '一个项目最多可包含 32 个文件夹。', invalidFolder: '请输入有效的主机绝对文件夹路径。', alreadyAdded: '已添加', multiFolderHelp: '使用复选框选择多个文件夹,浏览其他目录时会保留已选项。', hostFolderHelp: '文件夹位于主机计算机上;添加不会移动文件。', noSubfolders: '没有子文件夹。', directoryTruncated: '目录列表已截断,请输入路径浏览其他文件夹。',
|
|
58
|
+
rootFolder: '工作树根目录', rootSummary: '选择新工作树的创建位置。', rootHelp: '主机上的绝对目录路径,需位于源仓库之外。仅影响新工作树,已有目录和进行中的创建不会移动。', rootReset: '恢复继承的默认值', rootSaved: '已保存,新工作树将使用此位置。', rootUnavailable: '此条目的主机设置不可用。', rootInvalid: '请输入有效的主机绝对目录路径。', rootConflict: '设置已更改或主机拒绝了保存,草稿已保留。请重新打开页面读取最新值。',
|
|
59
|
+
pinned: '已固定', title: '工作树', local: '本地', new: '新工作树', remote: '远程', branch: '远程基础分支', search: '搜索远程分支',
|
|
60
|
+
setupProgress: '正在准备工作树', preparingMessage: '正在准备第一条消息…', fetchingLatest: '正在获取最新远程分支…', creatingWorktree: '正在创建工作树…', namingBranch: '正在生成并应用分支名称…', openingConversation: '正在打开工作树对话…', setupReady: '工作树已就绪', stagingAttachments: '正在为工作树准备附件…', startingConversation: '正在开始正常对话…', setupFailed: '工作树准备失败', cancelSetup: '取消准备', unsupported: '该输入区版本不受受保护适配器支持。', filesLost: '草稿附件已不可用。', upload: '附件上传失败', uncertain: '消息发送结果不确定,请先打开创建的对话检查。',
|
|
61
|
+
select: '基础分支', refresh: '刷新', more: '加载更多', close: '关闭', open: '打开对话', remove: '移除', creatingWorktree: '正在创建工作树…', createBranch: '在此创建分支', branchName: '新分支名称',
|
|
62
|
+
protect: '保护', unprotect: '取消保护', archive: '归档(保留文件)', restore: '恢复', archived: '已归档',
|
|
63
|
+
includeArchived: '包含已归档', preview: '预览交接', export: '导出补丁', apply: '应用到本地并继续',
|
|
64
|
+
previewTitle: '审阅本地交接', original: '原始本地检出', target: '本地目标路径',
|
|
65
|
+
openSource: '请先打开上方工作树对话,再预览或应用其交接。',
|
|
66
|
+
noWriters: '我已停止两个检出中的所有其他写入者,并会在交接期间保持停止。',
|
|
67
|
+
handoffWarning: '需要完整访问权限和空闲会话。本地必须干净且位于保存的基础提交。不会拉取、重置或切换分支。这不是全局原子操作;外部写入者必须保持停止。源文件和补丁会保留。',
|
|
68
|
+
actor: '请先打开对话,再使用工作树管理器。', detached: '分离检出', dirty: '有修改', clean: '干净',
|
|
69
|
+
protected: '已保护', unprotected: '未保护', base: '基础', sessions: '对话', state: '状态',
|
|
70
|
+
retained: '归档会保留目录和对话,不会删除文件。', operation: '操作', reconcile: '查询操作状态', cancelled: '已取消,已创建的工作树会保留。',
|
|
71
|
+
files: '文件', binary: '二进制', bytes: '字节', patch: '保留的补丁路径', copy: '复制路径', copied: '路径已复制',
|
|
72
|
+
transport: '传输失败', decode: '工作树响应无效或版本不受支持',
|
|
73
|
+
host: '主机拒绝操作', blocked: '输入区正在由其他插件使用,未更改其状态。',
|
|
74
|
+
ownership: '输入区的所有权已更改,已取消创建。', changed: '源会话、草稿、设置或导航已更改,已取消创建。',
|
|
75
|
+
targetBusy: '指定目标的输入区不是空闲且空白的,源草稿保持不变。',
|
|
76
|
+
invalidSession: '主机未返回明确的目标会话。', unknown: '操作失败', statusError: '检出状态不可用',
|
|
77
|
+
noRemote: '没有已公布的远程分支,请选择远程并刷新。不会使用缓存的基础提交。',
|
|
78
|
+
settings: '仓库设置已更改,请检查已创建的工作树。',
|
|
79
|
+
loading: '正在加载…', empty: '该仓库没有工作树。', idle: '空闲', busy: '忙碌', recovery: '需要恢复',
|
|
80
|
+
};
|
|
81
|
+
const css = `
|
|
82
|
+
.dsh-wt{color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.5;box-sizing:border-box}
|
|
83
|
+
.dsh-wt-panel{padding:24px;overflow:auto;height:100%;max-width:1100px;margin:auto}
|
|
84
|
+
.dsh-wt-card{padding:12px;border:1px solid var(--dsw-alias-border-l3);border-radius:10px;background:var(--dsw-alias-bg-layer-1);margin:8px 0}
|
|
85
|
+
.dsh-wt-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dsh-wt-between{justify-content:space-between}
|
|
86
|
+
.dsh-wt button{font:inherit;min-height:28px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l3);border-radius:6px;color:var(--dsw-alias-label-primary);background:transparent;cursor:pointer}
|
|
87
|
+
.dsh-wt button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
88
|
+
.dsh-wt button:disabled{opacity:.45;cursor:not-allowed}.dsh-wt button[aria-pressed=true],.dsh-wt .dsh-wt-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
|
|
89
|
+
.dsh-wt :is(button,input,select,textarea):focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
|
|
90
|
+
.dsh-wt :is(input:not([type=checkbox]),select,textarea){box-sizing:border-box;font:inherit;padding:6px 8px;max-width:100%;border:1px solid var(--dsw-alias-border-l4);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}
|
|
91
|
+
.dsh-wt label{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.dsh-wt-field{flex-direction:column;align-items:stretch!important;min-width:160px;flex:1}
|
|
92
|
+
.dsh-wt-muted{color:var(--dsw-alias-label-secondary);font-size:12px}.dsh-wt-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere}
|
|
93
|
+
.dsh-wt code,.dsh-wt pre{overflow-wrap:anywhere;white-space:pre-wrap}.dsh-wt h2,.dsh-wt h3,.dsh-wt p{margin:8px 0}
|
|
94
|
+
.dsh-wt-dialog{width:min(720px,calc(100% - 48px));max-height:calc(100% - 48px);overflow:auto;padding:20px;border:1px solid var(--dsw-alias-border-l3);border-radius:12px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
|
|
95
|
+
.dsh-wt-project-dialog{width:min(516px,calc(100vw - 32px));padding:20px;border-radius:16px;border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
|
|
96
|
+
.dsh-wt-project-dialog .dsh-wt-dialog-heading{margin:0 0 16px}.dsh-wt-project-dialog h2{margin:0;font-size:20px;line-height:1.3;font-weight:600}.dsh-wt-project-dialog .dsh-wt-dialog-close{display:inline-flex;align-items:center;justify-content:center;width:28px;min-height:28px;padding:0;border:0;color:var(--dsw-alias-label-secondary)}
|
|
97
|
+
.dsh-wt-project-name{display:flex;align-items:center;gap:10px!important;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;color:var(--dsw-alias-label-secondary)}.dsh-wt-project-name:focus-within{border-color:var(--dsw-alias-brand-primary)}.dsh-wt-project-dialog .dsh-wt-project-name input{flex:1;min-width:0;max-width:none;min-height:38px;padding:8px 0;border:0;border-radius:0;background:transparent;outline:none;color:var(--dsw-alias-label-primary)}
|
|
98
|
+
.dsh-wt-source-heading{display:flex;align-items:center;justify-content:space-between;margin:12px 0 8px;font-size:13px;font-weight:600}.dsh-wt-source-count{font-weight:400;color:var(--dsw-alias-label-secondary);font-size:12px}.dsh-wt-source-card{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;overflow:hidden}.dsh-wt-source-add{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-height:100px;padding:12px;box-sizing:border-box}.dsh-wt-source-card[data-filled=true] .dsh-wt-source-add{min-height:76px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
99
|
+
.dsh-wt-project-dialog .dsh-wt-source-select{border:0;background:transparent;max-width:100%;font-size:13px;text-align:center;color:var(--dsw-alias-label-secondary);padding:0 6px}.dsh-wt-project-dialog .dsh-wt-add-pill{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:2px 10px;min-height:26px;border:0;border-radius:999px;background:var(--dsw-alias-bg-layer-2);font-size:13px}.dsh-wt-project-dialog .dsh-wt-add-pill:hover:not(:disabled){background:var(--dsw-alias-bg-overlay)}
|
|
100
|
+
.dsh-wt-source-list{list-style:none;padding:0!important;margin:0!important;max-height:208px;overflow:auto}.dsh-wt-source-list li{display:flex;align-items:center;gap:10px;padding:10px 12px}.dsh-wt-source-list li+li{border-top:1px solid var(--dsw-alias-border-l1)}.dsh-wt-source-list svg{flex-shrink:0;color:var(--dsw-alias-label-secondary)}.dsh-wt-source-info{display:flex;flex-direction:column;flex:1;min-width:0}.dsh-wt-source-info>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-wt-source-path{color:var(--dsw-alias-label-secondary);font-size:12px}.dsh-wt-project-dialog .dsh-wt-source-remove{border:0;padding:0;width:28px;min-height:28px;color:var(--dsw-alias-label-secondary);flex-shrink:0}
|
|
101
|
+
.dsh-wt-project-path{width:100%;display:flex;gap:8px!important;flex-wrap:nowrap!important}.dsh-wt-project-path input{flex:1;min-width:0}.dsh-wt-project-footer{display:flex;justify-content:flex-end;align-items:center;gap:12px;margin-top:32px}.dsh-wt-project-dialog .dsh-wt-cancel{border:0;color:var(--dsw-alias-label-secondary)}.dsh-wt-project-dialog .dsh-wt-confirm{border:0;border-radius:10px;padding:6px 18px;min-height:32px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);font-size:13px}.dsh-wt-project-dialog .dsh-wt-confirm:hover:not(:disabled){background:var(--dsw-alias-label-secondary)}
|
|
102
|
+
.dsh-wt-project-dialog .dsh-wt-remove-project{margin-right:auto;border:0;color:var(--dsw-alias-state-error-primary);font-size:13px}.dsh-wt-project-dialog .dsh-wt-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
|
|
103
|
+
.dsh-wt-project-dialog .dsh-wt-main-folder{display:flex;flex-direction:column;align-items:stretch;gap:6px;margin-top:12px;font-size:13px}.dsh-wt-main-folder select{width:100%;min-width:0;border-color:var(--dsw-alias-border-l2)}.dsh-wt-main-folder small{color:var(--dsw-alias-label-secondary);font-size:12px}.dsh-wt-create-controls .dsh-wt-conversation-folder{max-width:180px}
|
|
104
|
+
.dsh-wt-folder-picker{width:min(640px,calc(100vw - 32px))}.dsh-wt-picker-path{display:flex;gap:8px;align-items:flex-end}.dsh-wt-picker-path label{flex:1;min-width:0}.dsh-wt-picker-crumbs{margin:12px 0;gap:4px}.dsh-wt-picker-entries{list-style:none;padding:0;margin:8px 0;max-height:240px;overflow:auto}.dsh-wt-picker-entries li,.dsh-wt-picker-current{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px}.dsh-wt-picker-entries li:hover{background:var(--dsw-alias-bg-layer-2)}.dsh-wt-picker-check{flex-shrink:0}.dsh-wt-picker-check input{accent-color:var(--dsw-alias-brand-primary)}.dsh-wt-picker-entries .dsh-wt-picker-open{border:0;flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-wt-picker-current{border:1px solid var(--dsw-alias-border-l1)}.dsh-wt-picker-selected{margin-top:12px}.dsh-wt-picker-selected .dsh-wt-source-list{max-height:128px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px}.dsh-wt-picker-selected h3{font-size:13px}.dsh-wt-picker-create{margin-top:12px}.dsh-wt-picker-create summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:13px}.dsh-wt-picker-create form{display:flex;align-items:flex-end;gap:8px;margin-top:8px}
|
|
105
|
+
.dsh-wt-progress{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);font-size:12px}.dsh-wt-progress ol{margin:8px 0;padding-left:18px;display:flex;flex-direction:column;gap:5px}.dsh-wt-progress li{color:var(--dsw-alias-label-secondary)}.dsh-wt-progress li[data-active=true]{font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
106
|
+
.dsh-wt-create-controls{position:relative;font-size:12px}.dsh-wt-create-controls select{max-width:160px;min-height:28px}.dsh-wt-base-options{position:absolute;bottom:calc(100% + 8px);left:0;z-index:20;min-width:240px;max-width:360px;padding:12px;display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-overlay)}.dsh-wt-create-error{max-width:360px;font-size:12px;color:var(--dsw-alias-state-error-primary)}
|
|
107
|
+
.dsh-wt-projects{min-height:0;display:flex;flex-direction:column;gap:6px;padding:8px;width:100%;box-sizing:border-box;font-size:13px}.dsh-wt-projects-header{display:flex;align-items:center;justify-content:space-between;padding:4px}.dsh-wt-project-list{min-height:0;overflow:auto}.dsh-wt-project-head{display:flex;align-items:center;gap:4px;margin-top:8px}.dsh-wt-project-head>button:first-child{flex:1;min-width:0;text-align:left;display:flex;align-items:center;gap:7px}.dsh-wt-projects button{border:0;padding:4px 6px}.dsh-wt-project-title,.dsh-wt-thread-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.dsh-wt-project-title{font-weight:600}.dsh-wt-thread{border-radius:7px;position:relative}.dsh-wt-thread[aria-selected=true]{background:var(--dsw-alias-interactive-bg-hover)}.dsh-wt-thread-main{display:flex;align-items:center;gap:5px;min-height:32px;padding-left:12px}.dsh-wt-thread-main>button:first-child{display:flex;align-items:center;gap:6px;flex:1;min-width:0;text-align:left}.dsh-wt-thread-title{flex:1}.dsh-wt-thread-main:hover .dsh-wt-thread-title{overflow:auto;text-overflow:clip}.dsh-wt-thread-actions{display:flex;align-items:center;opacity:0}.dsh-wt-thread:is(:hover,:focus-within) .dsh-wt-thread-actions{opacity:1}.dsh-wt-thread-actions button{min-width:24px}.dsh-wt-thread-leading{width:16px;display:inline-flex;flex-shrink:0}.dsh-wt-thread-status{font-size:11px;color:var(--dsw-alias-state-business-primary)}.dsh-wt-thread-pending{color:var(--dsw-alias-state-warning-primary)}.dsh-wt-thread-archived{opacity:.65}.dsh-wt-branch-chip{display:inline-flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;color:var(--dsw-alias-label-secondary)}.dsh-wt-thread-hover{padding:8px;border:1px solid var(--dsw-alias-border-l3);border-radius:6px;background:var(--dsw-alias-bg-layer-2)}.dsh-wt-project-menu{display:flex;flex-direction:column;align-items:stretch}.dsh-wt-project-menu button{text-align:left}.dsh-wt-project-picker{display:flex;align-items:center;gap:4px;max-width:100%}.dsh-wt-project-picker select{max-width:190px}.dsh-wt-folder-list{padding:0;list-style:none}.dsh-wt-folder-list li{display:flex;align-items:center;gap:8px;padding:4px 0;overflow-wrap:anywhere}.dsh-wt-folder-list code{flex:1}.dsh-wt-folder-browser{max-height:45vh;overflow:auto}.dsh-wt-folder-browser button{display:block;width:100%;text-align:left}.dsh-wt-dialog::backdrop{background:var(--dsw-alias-bg-mask-1)}.dsh-wt-details{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px}.dsh-wt-details dd{margin:0;overflow-wrap:anywhere}
|
|
108
|
+
/* Sidebar cell metrics follow the native Folder view; dialogs keep their own rules. */
|
|
109
|
+
.dsh-wt-projects{font-family:inherit;font-size:14px;font-weight:400;line-height:20px;gap:4px}.dsh-wt-projects-header{height:36px;box-sizing:border-box;padding:0 4px;margin:2px 0 4px;color:var(--dsw-alias-label-secondary)}.dsh-wt-projects-header strong{font-weight:inherit}.dsh-wt-projects-header>button{width:28px;height:28px;min-height:28px;padding:0}.dsh-wt-project-list{padding-bottom:16px}.dsh-wt-project-group+.dsh-wt-project-group{margin-top:4px}
|
|
110
|
+
.dsh-wt-project-head{height:34px;box-sizing:border-box;padding:0 8px;gap:6px;margin-top:2px;border-radius:8px}.dsh-wt-project-head:hover{background:var(--dsw-alias-interactive-bg-hover)}.dsh-wt-project-head>button:first-child{height:34px;min-height:34px;gap:6px;padding:0;background:transparent}.dsh-wt-project-title{font-size:14px;line-height:20px;font-weight:inherit}.dsh-wt-project-glyph{display:inline-flex;align-items:center;justify-content:center;width:16px;height:20px;flex:none;color:var(--dsw-alias-label-secondary)}.dsh-wt-project-chevron{display:none;font-size:14px}.dsh-wt-project-head:is(:hover,:focus-within) .dsh-wt-project-folder{display:none}.dsh-wt-project-head:is(:hover,:focus-within) .dsh-wt-project-chevron{display:inline-flex}
|
|
111
|
+
.dsh-wt-project-head-actions,.dsh-wt-thread-actions{display:inline-flex;align-items:center;gap:10px;max-width:0;height:20px;flex:none;overflow:hidden;opacity:0;pointer-events:none}.dsh-wt-project-head:is(:hover,:focus-within) .dsh-wt-project-head-actions,.dsh-wt-thread:is(:hover,:focus-within) .dsh-wt-thread-actions,.dsh-wt-thread[data-menu-open=true] .dsh-wt-thread-actions{max-width:none;overflow:visible;opacity:1;pointer-events:auto}.dsh-wt-project-head-actions button,.dsh-wt-thread-actions button{min-width:16px;width:16px;min-height:16px;height:16px;padding:0;color:var(--dsw-alias-label-secondary)}
|
|
112
|
+
.dsh-wt-thread{margin-top:2px;border-radius:8px}.dsh-wt-thread:hover,.dsh-wt-thread[data-menu-open=true]{background:var(--dsw-alias-interactive-bg-hover)}.dsh-wt-thread-main{height:32px;min-height:32px;padding:0 8px;gap:0;box-sizing:border-box}.dsh-wt-thread-main>button:first-child{height:32px;min-height:32px;padding:0;gap:0;font-size:14px;line-height:20px;font-weight:inherit;background:transparent}.dsh-wt-thread-title{margin:0 6px 0 4px;font-size:14px;line-height:20px}.dsh-wt-thread-leading{height:20px;align-items:center;justify-content:center;flex:none}.dsh-wt-branch-chip{margin-right:6px}.dsh-wt-thread-pin{margin-left:6px}.dsh-wt-thread-time{flex:none;font-size:10px;line-height:16px;color:var(--dsw-alias-label-secondary)}.dsh-wt-thread:is(:hover,:focus-within) .dsh-wt-thread-time,.dsh-wt-thread:is(:hover,:focus-within) .dsh-wt-thread-pin,.dsh-wt-thread[data-menu-open=true] .dsh-wt-thread-time,.dsh-wt-thread[data-menu-open=true] .dsh-wt-thread-pin{display:none}
|
|
113
|
+
.dsh-wt-projects .dsh-wt-session-overflow{display:block;width:100%;height:28px;min-height:28px;margin-top:0;padding:0 12px 0 28px;text-align:left;font-family:inherit;font-size:12px;font-weight:inherit;border:0;border-radius:0;color:var(--dsw-alias-label-secondary);background:transparent}.dsh-wt-projects .dsh-wt-session-overflow:hover:not(:disabled){background:transparent;color:var(--dsw-alias-label-primary)}
|
|
114
|
+
.dsh-wt-project-toolbar{position:relative;justify-content:flex-end;gap:4px;overflow:visible}.dsh-wt-project-section-label{margin-right:auto;min-width:0;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px}.dsh-wt-projects .dsh-wt-toolbar-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:28px;height:28px;min-height:28px;padding:0;border:0;border-radius:6px;color:var(--dsw-alias-label-secondary);background:transparent}.dsh-wt-projects .dsh-wt-toolbar-icon[data-active=true]{color:var(--dsw-alias-label-primary)}
|
|
115
|
+
.dsh-wt-project-search{display:flex;align-items:center;flex:1;min-width:0;border:1px solid var(--dsw-alias-border-l1);border-radius:6px}.dsh-wt-project-toolbar .dsh-wt-project-search-input{flex:1;min-width:0;width:100%;max-width:none;height:28px;padding:0 6px;border:0;background:transparent;font-family:inherit;font-size:13px;line-height:20px}.dsh-wt-project-search-input::-webkit-search-cancel-button{display:none}.dsh-wt-view-options{display:flex;align-items:center;flex:none}.dsh-wt-view-menu{position:absolute;top:calc(100% + 4px);right:0;z-index:30;min-width:200px;padding:6px;display:flex;flex-direction:column;gap:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary)}.dsh-wt-projects .dsh-wt-view-menu button{display:flex;align-items:center;gap:8px;padding:4px 8px;text-align:left;font-size:13px;line-height:20px;white-space:nowrap}.dsh-wt-view-check{width:16px;flex:none}
|
|
116
|
+
/* Native StateDot geometry and timing, owned locally rather than imported. */
|
|
117
|
+
.dsh-wt-state-dot{position:relative;display:inline-block;flex:none;width:10px;height:10px}.dsh-wt-state-dot::after{content:'';position:absolute;inset:20%;border-radius:50%;background:currentColor}.dsh-wt-state-dot[data-state=warning]{color:var(--dsw-alias-state-warn-primary)}.dsh-wt-state-dot[data-state=done]{color:var(--dsw-alias-state-success-primary)}
|
|
118
|
+
.dsh-wt-state-spinner{display:block;flex:none;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dsh-wt-state-motion{transform-origin:center;animation:dsh-wt-state-spin 1.5s linear infinite}.dsh-wt-state-track,.dsh-wt-state-arc{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}.dsh-wt-state-track{opacity:.25}.dsh-wt-state-arc{stroke-dasharray:12 150;animation:dsh-wt-state-dash 1.5s ease-in-out infinite}
|
|
119
|
+
@keyframes dsh-wt-state-spin{to{transform:rotate(360deg)}}@keyframes dsh-wt-state-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}100%{stroke-dasharray:12 150;stroke-dashoffset:0}}
|
|
120
|
+
@media(prefers-reduced-motion:reduce){.dsh-wt-state-motion,.dsh-wt-state-arc{animation:none}.dsh-wt-state-arc{stroke-dasharray:18 150;stroke-dashoffset:-3}}
|
|
121
|
+
`;
|
|
122
|
+
class WorktreeError extends Error {
|
|
123
|
+
constructor(kind, code, message = '') { super(message); this.kind = kind; this.code = code; }
|
|
124
|
+
}
|
|
125
|
+
function decodeReply(reply) {
|
|
126
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
127
|
+
const failure = value => object(value) && typeof value.code === 'string' && typeof value.message === 'string';
|
|
128
|
+
if (!object(reply) || typeof reply.ok !== 'boolean') throw new WorktreeError('decode', 'invalid-transport');
|
|
129
|
+
if (!reply.ok) {
|
|
130
|
+
if (!failure(reply.error)) throw new WorktreeError('decode', 'invalid-transport-error');
|
|
131
|
+
throw new WorktreeError('transport', reply.error.code, reply.error.message);
|
|
132
|
+
}
|
|
133
|
+
const envelope = reply.value;
|
|
134
|
+
if (!object(envelope) || envelope.v !== 1 || typeof envelope.ok !== 'boolean') throw new WorktreeError('decode', 'unsupported-envelope');
|
|
135
|
+
if (!envelope.ok) {
|
|
136
|
+
if (!failure(envelope.error)) throw new WorktreeError('decode', 'invalid-error');
|
|
137
|
+
throw new WorktreeError('host', envelope.error.code, envelope.error.message);
|
|
138
|
+
}
|
|
139
|
+
if (!Object.hasOwn(envelope, 'data')) throw new WorktreeError('decode', 'missing-data');
|
|
140
|
+
return envelope.data;
|
|
141
|
+
}
|
|
142
|
+
function validateData(request, data) {
|
|
143
|
+
const string = value => typeof value === 'string' && value.length > 0;
|
|
144
|
+
const naming = value => value && string(value.title) && string(value.slug) && string(value.directoryName) && string(value.branch) && ['model', 'fallback'].includes(value.source) && (value.fallbackReason === undefined || string(value.fallbackReason));
|
|
145
|
+
const record = value => value && (value.displayName === undefined || string(value.displayName)) && (value.naming === undefined || naming(value.naming)) && string(value.id) && string(value.operationId) && string(value.effectiveCwd) && string(value.remote) && string(value.remoteBranch) && typeof value.baseOid === 'string' && (value.state !== 'ready' || string(value.baseOid)) && Array.isArray(value.sessionIds) && value.sessionIds.every(string) && (value.branch === null || typeof value.branch === 'string') && typeof value.protected === 'boolean' && typeof value.archived === 'boolean' && ['creating', 'ready', 'recovery-required', 'missing'].includes(value.state);
|
|
146
|
+
const operation = value => value && (value.generatedName === undefined || naming(value.generatedName)) && string(value.id) && string(value.phase) && (value.error === null || (string(value.error?.code) && typeof value.error.message === 'string'));
|
|
147
|
+
let valid = true;
|
|
148
|
+
if (request.action === 'status') {
|
|
149
|
+
valid = data && string(data.settingsHash);
|
|
150
|
+
if (request.operationId) valid = valid && operation(data.operation) && (!data.sessionId || string(data.sessionId)) && (!data.worktree || record(data.worktree));
|
|
151
|
+
else if (request.id) valid = valid && record(data.worktree) && (data.status === null || (typeof data.status?.dirty === 'boolean' && string(data.status.head)));
|
|
152
|
+
else valid = valid && string(data.repository?.root) && string(data.repository.head) && (data.repository.branch === null || typeof data.repository.branch === 'string') && Array.isArray(data.remotes) && data.remotes.every(item => string(item?.name) && string(item.identity));
|
|
153
|
+
} else if (request.action === 'branches') {
|
|
154
|
+
valid = data && data.remote === request.remote && string(data.remoteIdentity) && (!request.remoteIdentity || data.remoteIdentity === request.remoteIdentity) && Array.isArray(data.items) && data.items.every(item => string(item?.name) && string(item.oid)) && Number.isFinite(data.observedAt) && (data.defaultBranch === null || typeof data.defaultBranch === 'string') && (!data.nextCursor || string(data.nextCursor));
|
|
155
|
+
} else if (request.action === 'list') valid = data && Array.isArray(data.items) && data.items.every(record) && (!data.nextCursor || string(data.nextCursor));
|
|
156
|
+
else if (['create', 'start', 'handoff'].includes(request.action)) valid = data && string(data.sessionId) && string(data.workspaceId) && string(data.settingsHash) && record(data.worktree) && operation(data.operation);
|
|
157
|
+
else if (request.action === 'preview') valid = data && string(data.id) && data.worktreeId === request.id && string(data.sourceSessionId) && string(data.targetRoot) && string(data.baseOid) && string(data.patchHash) && string(data.patchPath) && Number.isFinite(data.bytes) && Array.isArray(data.files) && data.files.every(file => string(file?.path) && typeof file.status === 'string' && typeof file.binary === 'boolean');
|
|
158
|
+
else if (request.action === 'export') valid = data && string(data.path) && string(data.patchHash) && Number.isFinite(data.bytes) && (data.patch === undefined || typeof data.patch === 'string');
|
|
159
|
+
if (!valid) throw new WorktreeError('decode', 'invalid-' + request.action + '-data');
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
async function quietRPC(ctx, endpoint, payload, signal) {
|
|
163
|
+
let reply;
|
|
164
|
+
// Use Connection's authenticated/correlated transport, never chat commands.
|
|
165
|
+
try { reply = await ctx.connection.rpc.call('/api', endpoint, payload, signal); }
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error?.name === 'AbortError') throw new WorktreeError('cancelled', 'cancelled', error.message || '');
|
|
168
|
+
throw new WorktreeError('transport', 'rpc-rejected', error?.message || '');
|
|
169
|
+
}
|
|
170
|
+
return decodeReply(reply);
|
|
171
|
+
}
|
|
172
|
+
async function requestHost(ctx, actorId, request, signal) {
|
|
173
|
+
if (typeof actorId !== 'string' || !actorId.trim()) throw new WorktreeError('actor', 'no-current-conversation');
|
|
174
|
+
return validateData(request, await quietRPC(ctx, 'dsh-worktrees/execute', { v: 1, actorId, request }, signal));
|
|
175
|
+
}
|
|
176
|
+
function report(t, error) {
|
|
177
|
+
if (error instanceof WorktreeError) return t(error.kind) + (error.code ? ' [' + error.code + ']' : '') + (error.message ? ': ' + error.message : '');
|
|
178
|
+
return t('unknown') + (error?.message ? ': ' + error.message : '');
|
|
179
|
+
}
|
|
180
|
+
function currentActor(list) {
|
|
181
|
+
const ids = Object.keys(list.byId).filter(id => (list.byId[id].retainedBy?.mainView || 0) > 0 && list.byId[id].origin !== 'subagent');
|
|
182
|
+
return ids.length === 1 ? ids[0] : undefined;
|
|
183
|
+
}
|
|
184
|
+
function sourceEligible(snapshot) {
|
|
185
|
+
return snapshot.blank === true && snapshot.subagent === null && snapshot.openState === 'open' && !snapshot.removed && !snapshot.running && !snapshot.promptAttempted && !snapshot.awaitingFirstTurn && snapshot.pendingSubmissions.length === 0;
|
|
186
|
+
}
|
|
187
|
+
function inputEmpty(input) { return input.phase === 'plain' && input.draft === '' && input.attachmentIds.length === 0 && input.queue.length === 0; }
|
|
188
|
+
function captureDraft(input) { return { draft: input.draft, draftRev: input.draftRev, attachmentIds: [...input.attachmentIds], phase: input.phase }; }
|
|
189
|
+
function draftUnchanged(before, after) {
|
|
190
|
+
return before.phase === 'plain' && after.phase === 'plain' && before.draft === after.draft && before.draftRev === after.draftRev && before.attachmentIds.length === after.attachmentIds.length && before.attachmentIds.every((id, i) => id === after.attachmentIds[i]) && after.queue.length === 0;
|
|
191
|
+
}
|
|
192
|
+
function advertisedDefault(data, preferred = 'main') { return data.items.some(item => item.name === preferred) ? preferred : data.items.some(item => item.name === data.defaultBranch) ? data.defaultBranch : ''; }
|
|
193
|
+
// The block setter compares reasons. Ownership is the *actual stored object*, never just its reason.
|
|
194
|
+
function acquireBlock(blocks, sessionId, reason) {
|
|
195
|
+
const store = blocks.storeFor(sessionId);
|
|
196
|
+
if (store.getSnapshot() !== undefined) throw new WorktreeError('blocked', 'composer-already-blocked');
|
|
197
|
+
const owned = Object.freeze({ reason });
|
|
198
|
+
blocks.set(sessionId, owned);
|
|
199
|
+
if (store.getSnapshot() !== owned) throw new WorktreeError('ownership', 'block-not-acquired');
|
|
200
|
+
return { owned, store, release() { if (store.getSnapshot() === owned) blocks.set(sessionId, undefined); } };
|
|
201
|
+
}
|
|
202
|
+
function releaseAfterMainOwnership(ctx, reference, committed) {
|
|
203
|
+
const ownership = ctx.sessions.retainInfo(reference.sessionId);
|
|
204
|
+
const mainOwns = () => (ownership.getSnapshot().retainedBy.mainView || 0) > 0;
|
|
205
|
+
if (!committed || mainOwns()) { reference.release(); return; }
|
|
206
|
+
// A rare openSession failure after the synchronous Client commit must not
|
|
207
|
+
// dispose the only destination generation and silently lose its draft.
|
|
208
|
+
// Park our reference until the user opens this exact session, or plugin unload.
|
|
209
|
+
let unsubscribe = () => {};
|
|
210
|
+
const dispose = ctx.effect(() => () => { unsubscribe(); reference.release(); });
|
|
211
|
+
unsubscribe = ownership.subscribe(() => { if (mainOwns()) dispose(); });
|
|
212
|
+
if (mainOwns()) dispose();
|
|
213
|
+
}
|
|
214
|
+
const absolutePath = path => typeof path === 'string' && !path.includes('\0') && (/^\//.test(path) || /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path));
|
|
215
|
+
const uuid = id => typeof id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
|
|
216
|
+
function appendProjectFolders(previous, additions) {
|
|
217
|
+
if (!Array.isArray(additions) || additions.some(path => !absolutePath(path) || path.length > 4096 || /[\u0000-\u001f\u007f]/u.test(path))) throw new WorktreeError('invalidFolder', 'invalid-project-folder');
|
|
218
|
+
// Exact-string deduplication is only UX; realpath/ownership checks stay on the Host.
|
|
219
|
+
const next = [...new Set([...previous, ...additions])];
|
|
220
|
+
if (next.length > 32) throw new WorktreeError('folderLimit', 'project-folder-limit');
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
function toggleProjectFolder(previous, path, capacity = 32) {
|
|
224
|
+
if (previous.includes(path)) return previous.filter(value => value !== path);
|
|
225
|
+
const next = appendProjectFolders(previous, [path]);
|
|
226
|
+
if (next.length > capacity) throw new WorktreeError('folderLimit', 'project-folder-limit');
|
|
227
|
+
return next;
|
|
228
|
+
}
|
|
229
|
+
function projectFolderName(path) {
|
|
230
|
+
const separator = /^[A-Za-z]:[\\/]|^\\\\/.test(path) ? /[\\/]/ : /\//;
|
|
231
|
+
return path.split(separator).filter(Boolean).at(-1) || path;
|
|
232
|
+
}
|
|
233
|
+
function createFolderScanner(uiWorkspace, publish) {
|
|
234
|
+
let active, generation = 0, disposed = false;
|
|
235
|
+
return {
|
|
236
|
+
async scan(path) {
|
|
237
|
+
if (disposed) return;
|
|
238
|
+
active?.abort(); const controller = new AbortController(); active = controller; const seq = ++generation;
|
|
239
|
+
publish({ busy: true, error: null });
|
|
240
|
+
try { const listing = await uiWorkspace.listDirectory(path, controller.signal); if (!disposed && !controller.signal.aborted && seq === generation) publish({ listing }); }
|
|
241
|
+
catch (error) { if (!disposed && !controller.signal.aborted && seq === generation) publish({ error }); }
|
|
242
|
+
finally { if (!disposed && !controller.signal.aborted && seq === generation) publish({ busy: false }); }
|
|
243
|
+
},
|
|
244
|
+
dispose() { disposed = true; ++generation; active?.abort(); },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function projectMainFolder(project) {
|
|
248
|
+
return project?.mainFolderId === undefined ? project?.folders[0] : project.folders.find(folder => folder.id === project.mainFolderId);
|
|
249
|
+
}
|
|
250
|
+
function draftMainFolder(folders, selected) { return folders.length === 1 ? folders[0] : folders.includes(selected) ? selected : ''; }
|
|
251
|
+
function chooseConversationFolder(ctx, projects, flow, sessionId, folderId) {
|
|
252
|
+
const binding = ctx.sessions.binding(sessionId), context = projects.context(sessionId), state = flow.store.getSnapshot();
|
|
253
|
+
if (!binding || projectActor(ctx.sessions.list.getSnapshot()) !== sessionId || !sourceEligible(binding.session.getSnapshot()) || !inputEmpty(ctx.get('conversation').input.for(binding.ctx).state.getSnapshot()) || (state.sessionId === sessionId && state.busy)) throw new WorktreeError('draftWarning', 'folder-change-blocked');
|
|
254
|
+
const folder = context?.project.folders.find(item => item.id === folderId);
|
|
255
|
+
if (!folder) throw new WorktreeError('decode', 'invalid-project-folder');
|
|
256
|
+
if (folder.id === context.folder.id && context.binding.mode === 'local') return false;
|
|
257
|
+
ctx.uiWorkspace.startSession(folder.id); return true;
|
|
258
|
+
}
|
|
259
|
+
function createProjectSubmitter(projects, projectId, id) {
|
|
260
|
+
let pending, pendingAction;
|
|
261
|
+
function commit(request) {
|
|
262
|
+
Object.freeze(request.folders); Object.freeze(request);
|
|
263
|
+
const task = Promise.resolve().then(() => projects.mutate(request)); pending = task; pendingAction = request.action;
|
|
264
|
+
const clear = () => { if (pending === task) { pending = undefined; pendingAction = undefined; } }; void task.then(clear, clear);
|
|
265
|
+
return task;
|
|
266
|
+
}
|
|
267
|
+
const conflict = () => { throw new WorktreeError('busy', 'project-operation-pending'); };
|
|
268
|
+
return {
|
|
269
|
+
get pending() { return !!pending; },
|
|
270
|
+
submit(title, folders, selectedMain) {
|
|
271
|
+
if (pending) return pendingAction === 'remove' ? conflict() : pending;
|
|
272
|
+
const savedFolders = appendProjectFolders([], folders), mainFolder = draftMainFolder(savedFolders, selectedMain);
|
|
273
|
+
if (savedFolders.length && !mainFolder) throw new WorktreeError('mainFolderRequired', 'main-folder-required');
|
|
274
|
+
return commit(projectRequest({ ...(projectId ? { action: 'update', projectId } : { action: 'create', id }), title: title.trim(), folders: savedFolders, ...(mainFolder ? { mainFolder } : {}) }));
|
|
275
|
+
},
|
|
276
|
+
remove() {
|
|
277
|
+
if (!projectId) throw new WorktreeError('decode', 'remove-requires-saved-project');
|
|
278
|
+
if (pending) return pendingAction === 'remove' ? pending : conflict();
|
|
279
|
+
return commit(projectRequest({ action: 'remove', projectId }));
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function projectRequest(request) {
|
|
284
|
+
const keys = { list: ['action', 'projectId'], create: ['action', 'id', 'title', 'folders', 'mainFolder'], update: ['action', 'projectId', 'title', 'folders', 'mainFolder'], remove: ['action', 'projectId'], bind: ['action', 'projectId', 'folderId', 'sessionId'], start: ['action', 'operationId', 'projectId', 'folderId'] }[request?.action];
|
|
285
|
+
const title = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 120;
|
|
286
|
+
const folders = value => Array.isArray(value) && value.length > 0 && value.length <= 32 && value.every(absolutePath);
|
|
287
|
+
const main = value => value === undefined || (absolutePath(value) && value.length <= 4096 && !/[\u0000-\u001f\u007f]/u.test(value));
|
|
288
|
+
const folderId = value => typeof value === 'string' && value.length > 0;
|
|
289
|
+
let valid = keys && Object.keys(request).every(key => keys.includes(key));
|
|
290
|
+
if (request?.action === 'list') valid = valid && (request.projectId === undefined || uuid(request.projectId));
|
|
291
|
+
if (request?.action === 'create') valid = valid && uuid(request.id) && title(request.title) && folders(request.folders) && main(request.mainFolder);
|
|
292
|
+
if (request?.action === 'update') valid = valid && uuid(request.projectId) && (request.title !== undefined || request.folders !== undefined || request.mainFolder !== undefined) && (request.title === undefined || title(request.title)) && (request.folders === undefined || folders(request.folders)) && main(request.mainFolder);
|
|
293
|
+
if (request?.action === 'remove') valid = valid && uuid(request.projectId);
|
|
294
|
+
if (request?.action === 'bind') valid = valid && uuid(request.projectId) && folderId(request.folderId) && typeof request.sessionId === 'string' && !!request.sessionId;
|
|
295
|
+
if (request?.action === 'start') valid = valid && uuid(request.projectId) && uuid(request.operationId) && (request.folderId === undefined || folderId(request.folderId));
|
|
296
|
+
if (!valid) throw new WorktreeError('decode', 'invalid-project-request');
|
|
297
|
+
return request;
|
|
298
|
+
}
|
|
299
|
+
function projectData(request, data) {
|
|
300
|
+
const string = value => typeof value === 'string' && value.length > 0;
|
|
301
|
+
const project = value => value && uuid(value.id) && string(value.title) && value.title.length <= 120 && Array.isArray(value.folders) && value.folders.length > 0 && value.folders.length <= 32 && value.folders.every(folder => string(folder?.id) && absolutePath(folder.path) && typeof folder.title === 'string') && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && !Object.is(value.createdAt, -0) && Number.isSafeInteger(value.updatedAt) && value.updatedAt >= 0 && !Object.is(value.updatedAt, -0) && (value.imported === undefined || typeof value.imported === 'boolean') && (value.mainFolderId === undefined || (string(value.mainFolderId) && value.folders.some(folder => folder.id === value.mainFolderId)));
|
|
302
|
+
const binding = value => value && string(value.sessionId) && uuid(value.projectId) && string(value.folderId) && ['local', 'worktree'].includes(value.mode) && absolutePath(value.effectiveCwd) && (value.mode === 'worktree' ? uuid(value.worktreeId) : value.worktreeId === undefined);
|
|
303
|
+
let valid;
|
|
304
|
+
if (request.action === 'list') {
|
|
305
|
+
valid = data && Array.isArray(data.projects) && data.projects.every(project) && Array.isArray(data.bindings) && data.bindings.every(binding);
|
|
306
|
+
if (valid && data.records !== undefined) validateData({ action: 'list' }, { items: data.records });
|
|
307
|
+
}
|
|
308
|
+
else if (request.action === 'start') valid = data && string(data.sessionId) && binding(data.binding) && data.workspaceId === data.binding.folderId && data.binding.sessionId === data.sessionId && data.binding.projectId === request.projectId && (request.folderId === undefined || data.binding.folderId === request.folderId) && data.binding.mode === 'local';
|
|
309
|
+
else if (request.action === 'remove') valid = data && data.removed === true && data.projectId === request.projectId && data.scope === 'project-metadata' && Object.keys(data).every(key => ['removed', 'projectId', 'scope'].includes(key));
|
|
310
|
+
else if (request.action === 'bind') valid = data && binding(data.binding) && data.binding.sessionId === request.sessionId && data.binding.projectId === request.projectId && data.binding.folderId === request.folderId;
|
|
311
|
+
else valid = data && project(data.project) && data.project.id === (request.id || request.projectId);
|
|
312
|
+
if (!valid) throw new WorktreeError('decode', 'invalid-project-' + request.action + '-data');
|
|
313
|
+
return data;
|
|
314
|
+
}
|
|
315
|
+
async function requestProjects(ctx, actorId, request, signal) {
|
|
316
|
+
projectRequest(request);
|
|
317
|
+
if (actorId !== undefined && (typeof actorId !== 'string' || !actorId.trim())) throw new WorktreeError('actor', 'invalid-project-actor');
|
|
318
|
+
return projectData(request, await quietRPC(ctx, 'dsh-worktrees/projects', { v: 1, request, ...(actorId === undefined ? {} : { actorId }) }, signal));
|
|
319
|
+
}
|
|
320
|
+
function workspaceStructure(snapshot) {
|
|
321
|
+
return JSON.stringify([snapshot.phase, snapshot.items.map(item => [item.workspaceId, item.path, item.title, item.sessionIds])]);
|
|
322
|
+
}
|
|
323
|
+
function projectContext(snapshot, workspaces, sessionId) {
|
|
324
|
+
const binding = snapshot.bindings.find(item => item.sessionId === sessionId);
|
|
325
|
+
if (binding) {
|
|
326
|
+
const project = snapshot.projects.find(item => item.id === binding.projectId);
|
|
327
|
+
const folder = project?.folders.find(item => item.id === binding.folderId);
|
|
328
|
+
return project && folder ? { project, folder, binding } : undefined;
|
|
329
|
+
}
|
|
330
|
+
// Local membership, not fork lineage, is the only safe fallback. Never
|
|
331
|
+
// classify a fork as a worktree merely because it has a parentId.
|
|
332
|
+
const workspace = workspaces.items.find(item => item.sessionIds.includes(sessionId));
|
|
333
|
+
const project = snapshot.projects.find(item => item.folders.some(folder => folder.id === workspace?.workspaceId));
|
|
334
|
+
const folder = project?.folders.find(item => item.id === workspace?.workspaceId);
|
|
335
|
+
return folder ? { project, folder, binding: { sessionId, projectId: project.id, folderId: folder.id, mode: 'local', effectiveCwd: folder.path } } : undefined;
|
|
336
|
+
}
|
|
337
|
+
function projectBacking(snapshot, workspaces, sessionId) {
|
|
338
|
+
const context = projectContext(snapshot, workspaces, sessionId), workspace = workspaces.items.find(item => item.sessionIds.includes(sessionId));
|
|
339
|
+
// Actual native cwd wins over historical membership/sessionIds, including a Local handoff.
|
|
340
|
+
const effectiveCwd = workspace?.path || context?.binding.effectiveCwd || '';
|
|
341
|
+
const record = (snapshot.records || []).find(item => item.effectiveCwd === effectiveCwd);
|
|
342
|
+
const boundWorktree = context?.binding.mode === 'worktree' && context.binding.effectiveCwd === effectiveCwd;
|
|
343
|
+
return { mode: record || boundWorktree ? 'worktree' : 'local', effectiveCwd, record };
|
|
344
|
+
}
|
|
345
|
+
function captureProject(context) { return context ? Object.freeze({ projectId: context.project.id, folderId: context.folder.id, path: context.folder.path }) : undefined; }
|
|
346
|
+
function sameProject(before, after) { return before?.projectId === after?.projectId && before?.folderId === after?.folderId && before?.path === after?.path; }
|
|
347
|
+
function projectPath(status, captured) { return captured?.path || status.projectPath || status.repository.root; }
|
|
348
|
+
function projectRows(metadata, workspaces, sessions, statuses, { query = '', archived = 'hide', selectedId } = {}) {
|
|
349
|
+
const archives = new Set(workspaces.archivedSessionIds), pins = new Set(workspaces.pinnedSessionIds);
|
|
350
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
351
|
+
const groups = metadata.projects.map(project => ({ project, rows: [] }));
|
|
352
|
+
const unassigned = { project: null, rows: [] };
|
|
353
|
+
for (const [id, session] of Object.entries(sessions.byId)) {
|
|
354
|
+
if (session.origin === 'subagent' || (session.blank && id !== selectedId)) continue;
|
|
355
|
+
const isArchived = archives.has(id);
|
|
356
|
+
if ((archived === 'hide' && isArchived) || (archived === 'only' && !isArchived)) continue;
|
|
357
|
+
const context = projectContext(metadata, workspaces, id);
|
|
358
|
+
const title = session.title || '';
|
|
359
|
+
if (needle && ![title, context?.project.title, context?.folder.title, context?.folder.path].some(value => value?.toLocaleLowerCase().includes(needle))) continue;
|
|
360
|
+
const live = statuses.get(id);
|
|
361
|
+
const children = sessions.projectionsBySession?.[id]?.values.subagentCatalog || [];
|
|
362
|
+
const childRunning = children.some(child => (statuses.get(child.id)?.running ?? sessions.byId[child.id]?.running) === true);
|
|
363
|
+
const row = { id, session, title, context, backing: projectBacking(metadata, workspaces, id), archived: isArchived, pinned: !isArchived && pins.has(id), running: (live?.running ?? session.running ?? false) || childRunning, pending: live?.pendingInteraction, done: live?.completionUnread === true };
|
|
364
|
+
(groups.find(group => group.project.id === context?.project.id) || unassigned).rows.push(row);
|
|
365
|
+
}
|
|
366
|
+
for (const group of [...groups, unassigned]) group.rows.sort((a, b) => Number(b.pinned) - Number(a.pinned) || (b.session.updatedAt || 0) - (a.session.updatedAt || 0) || a.id.localeCompare(b.id));
|
|
367
|
+
return [...groups.filter(group => archived !== 'only' || group.rows.length), ...(unassigned.rows.length ? [unassigned] : [])];
|
|
368
|
+
}
|
|
369
|
+
function projectRowState(row) {
|
|
370
|
+
if (row.archived || row.session.blank) return 'idle';
|
|
371
|
+
return row.pending ? 'warning' : row.running ? 'ongoing' : row.done ? 'done' : 'idle';
|
|
372
|
+
}
|
|
373
|
+
function projectSessionTime(at, now, t) {
|
|
374
|
+
if (!Number.isFinite(at) || at < 0 || at > 8640000000000000 || !Number.isFinite(now)) return null;
|
|
375
|
+
const minute = 60000, hour = 3600000, day = 86400000, diff = Math.max(0, now - at);
|
|
376
|
+
const [unit, n] = diff < minute ? ['now', 0] : diff < hour ? ['minutes', Math.floor(diff / minute)] : diff < day ? ['hours', Math.floor(diff / hour)] : diff < 30 * day ? ['days', Math.floor(diff / day)] : diff < 365 * day ? ['months', Math.floor(diff / (30 * day))] : ['years', Math.floor(diff / (365 * day))];
|
|
377
|
+
const date = new Date(at), clock = String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0');
|
|
378
|
+
const absolute = t('date.ymd', { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() }) + ' ' + clock;
|
|
379
|
+
return { label: unit === 'now' ? t('time.now') : t('time.' + unit, { n }), dateTime: date.toISOString(), title: t('lastActive', { time: absolute }) };
|
|
380
|
+
}
|
|
381
|
+
function visibleProjectRows(rows, limit, selectedId) {
|
|
382
|
+
let idle = 0;
|
|
383
|
+
return rows.filter(row => row.session?.blank || row.running || row.pending || row.id === selectedId || idle++ < limit);
|
|
384
|
+
}
|
|
385
|
+
function projectActor(list) {
|
|
386
|
+
const ids = Object.keys(list.byId).filter(id => (list.byId[id].retainedBy?.mainView || 0) > 0 && list.byId[id].origin !== 'subagent');
|
|
387
|
+
return ids.length === 1 ? ids[0] : undefined;
|
|
388
|
+
}
|
|
389
|
+
function projectReady(reference, signal) {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const abort = () => { signal.removeEventListener('abort', abort); reject(new WorktreeError('cancelled', 'cancelled')); };
|
|
392
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
393
|
+
Promise.resolve(reference.ready).then(binding => { signal.removeEventListener('abort', abort); resolve(binding); }, error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
394
|
+
if (signal.aborted) abort();
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// One root-owned metadata cache. Reconnect and structural Workspace changes
|
|
398
|
+
// refresh it; token/status updates and component mounts never issue reads.
|
|
399
|
+
function createProjectStore(ctx, t) {
|
|
400
|
+
let state = { projects: [], bindings: [], records: [], loading: false, error: '' }, serialized = '', disposed = false;
|
|
401
|
+
let refreshRequest, sequence = 0, queued = false, structure = workspaceStructure(ctx.workspaces.list.getSnapshot());
|
|
402
|
+
let generation = ctx.connection.generation.getSnapshot()?.id;
|
|
403
|
+
const listeners = new Set(), operations = new Set();
|
|
404
|
+
const publish = patch => { if (disposed) return; state = { ...state, ...patch }; for (const listener of [...listeners]) listener(); };
|
|
405
|
+
const store = { getSnapshot: () => state, subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
406
|
+
const context = id => projectContext(state, ctx.workspaces.list.getSnapshot(), id);
|
|
407
|
+
function install(data) {
|
|
408
|
+
const next = JSON.stringify(data);
|
|
409
|
+
if (next !== serialized) { serialized = next; publish(data); }
|
|
410
|
+
}
|
|
411
|
+
async function refresh() {
|
|
412
|
+
if (disposed || operations.size || ctx.connection.generation.getSnapshot() === undefined) return;
|
|
413
|
+
refreshRequest?.abort(); refreshRequest = new AbortController(); const controller = refreshRequest, seq = ++sequence;
|
|
414
|
+
publish({ loading: true, error: '' });
|
|
415
|
+
try {
|
|
416
|
+
const data = await requestProjects(ctx, undefined, { action: 'list' }, controller.signal);
|
|
417
|
+
if (!disposed && !controller.signal.aborted && seq === sequence) { install(data); publish({ loading: false }); }
|
|
418
|
+
} catch (failure) { if (!disposed && !controller.signal.aborted && seq === sequence) publish({ loading: false, error: report(t, failure) }); }
|
|
419
|
+
}
|
|
420
|
+
function schedule() {
|
|
421
|
+
if (queued || disposed) return; queued = true;
|
|
422
|
+
Promise.resolve().then(() => { queued = false; if (!disposed) void refresh(); });
|
|
423
|
+
}
|
|
424
|
+
const unsubscribeWorkspace = ctx.workspaces.list.subscribe(() => {
|
|
425
|
+
const next = workspaceStructure(ctx.workspaces.list.getSnapshot());
|
|
426
|
+
if (next !== structure) { structure = next; schedule(); }
|
|
427
|
+
});
|
|
428
|
+
const unsubscribeConnection = ctx.connection.generation.subscribe(() => {
|
|
429
|
+
const next = ctx.connection.generation.getSnapshot()?.id;
|
|
430
|
+
if (next !== generation) { generation = next; refreshRequest?.abort(); ++sequence; for (const op of operations) op.controller.abort(); if (next !== undefined) schedule(); }
|
|
431
|
+
});
|
|
432
|
+
async function mutate(request) {
|
|
433
|
+
if (disposed) throw new WorktreeError('cancelled', 'project-store-closed');
|
|
434
|
+
const controller = new AbortController(), op = { controller }; operations.add(op);
|
|
435
|
+
// A read before or during a mutation cannot publish stale ownership.
|
|
436
|
+
refreshRequest?.abort(); ++sequence;
|
|
437
|
+
try {
|
|
438
|
+
const actorId = request.action === 'remove' ? undefined : projectActor(ctx.sessions.list.getSnapshot());
|
|
439
|
+
const data = await requestProjects(ctx, actorId, request, controller.signal);
|
|
440
|
+
if (disposed || controller.signal.aborted) throw new WorktreeError('cancelled', 'cancelled');
|
|
441
|
+
refreshRequest?.abort(); ++sequence;
|
|
442
|
+
if (request.action === 'remove') install({ projects: state.projects.filter(item => item.id !== data.projectId), bindings: state.bindings.filter(item => item.projectId !== data.projectId) });
|
|
443
|
+
if (data.project) install({ projects: [...state.projects.filter(item => item.id !== data.project.id), data.project], bindings: state.bindings });
|
|
444
|
+
if (data.binding) install({ projects: state.projects, bindings: [...state.bindings.filter(item => item.sessionId !== data.binding.sessionId), data.binding] });
|
|
445
|
+
return data;
|
|
446
|
+
} finally { operations.delete(op); if (!operations.size) schedule(); }
|
|
447
|
+
}
|
|
448
|
+
const api = { store, context, refresh, mutate,
|
|
449
|
+
rememberRecords(records) { publish({ records: [...state.records.filter(item => !records.some(record => record.id === item.id)), ...records] }); },
|
|
450
|
+
rememberWorktree(result, captured) {
|
|
451
|
+
api.rememberRecords([result.worktree]);
|
|
452
|
+
if (captured) install({ projects: state.projects, bindings: [...state.bindings.filter(item => item.sessionId !== result.sessionId), { sessionId: result.sessionId, projectId: captured.projectId, folderId: captured.folderId, mode: 'worktree', effectiveCwd: result.worktree.effectiveCwd, worktreeId: result.worktree.id }] });
|
|
453
|
+
schedule();
|
|
454
|
+
},
|
|
455
|
+
dispose() { disposed = true; ++sequence; refreshRequest?.abort(); unsubscribeWorkspace(); unsubscribeConnection(); for (const op of operations) op.controller.abort(); operations.clear(); listeners.clear(); },
|
|
456
|
+
};
|
|
457
|
+
schedule(); return api;
|
|
458
|
+
}
|
|
459
|
+
function rootFolderValue(state) { return typeof state?.value?.root === 'string' ? state.value.root : ''; }
|
|
460
|
+
async function writeRootFolder(form, draft, expectedRevision, reset = false) {
|
|
461
|
+
const state = form?.state;
|
|
462
|
+
if (!state || state.status !== 'ready' || !state.writable || state.mode !== 'host' || typeof form.mutate !== 'function') throw new WorktreeError('rootUnavailable', 'settings-unavailable');
|
|
463
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new WorktreeError('rootUnavailable', 'missing-settings-revision');
|
|
464
|
+
const root = typeof draft === 'string' ? draft.trim() : '';
|
|
465
|
+
if (!reset && (!absolutePath(root) || root.length > 4096 || /[\u0000-\u001f\u007f]/u.test(root))) throw new WorktreeError('rootInvalid', 'invalid-root-path');
|
|
466
|
+
const accepted = await form.mutate([reset ? { op: 'unset', path: ['root'] } : { op: 'set', path: ['root'], value: root }], expectedRevision);
|
|
467
|
+
if (accepted !== true) throw new WorktreeError('rootConflict', 'root-setting-refused');
|
|
468
|
+
return root;
|
|
469
|
+
}
|
|
470
|
+
const NATIVE_SUBMIT_ABI = '0.2.0-rc.2/default-sink-v1';
|
|
471
|
+
const later = callback => { void Promise.resolve().then(() => Promise.resolve()).then(callback); };
|
|
472
|
+
function interrupted(promise, signal) {
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
const abort = () => { cleanup(); reject(new WorktreeError('cancelled', 'cancelled')); };
|
|
475
|
+
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
476
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
477
|
+
Promise.resolve(promise).then(value => { cleanup(); resolve(value); }, error => { cleanup(); reject(error); });
|
|
478
|
+
if (signal.aborted) abort();
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
// Approved, exact-version compatibility boundary. Only constructor callbacks
|
|
482
|
+
// are leased: editor, submit machine, commands, codecs and rollback stay native.
|
|
483
|
+
// Never replace a service, keyboard handler, DOM node, or native prompt route.
|
|
484
|
+
function leaseNativeSink(input, binding, hooks) {
|
|
485
|
+
const own = (object, key) => Object.getOwnPropertyDescriptor(object, key);
|
|
486
|
+
const slot = own(input, 'deps'), deps = slot && 'value' in slot && slot.value;
|
|
487
|
+
const sink = deps && own(deps, 'defaultSink'), triggers = deps && own(deps, 'inputTriggers'), actor = deps && own(deps, 'actx');
|
|
488
|
+
if (!deps || !actor || !('value' in actor) || actor.value !== binding.ctx || !sink || !('value' in sink) || !sink.writable || typeof sink.value !== 'function' || sink.value.length !== 4 || !triggers || !('value' in triggers) || !triggers.writable || typeof triggers.value !== 'function') throw new WorktreeError('unsupported', 'native-submit-abi', NATIVE_SUBMIT_ABI);
|
|
489
|
+
const original = sink.value, originalTriggers = triggers?.value, tokens = new Map(), active = new Set();
|
|
490
|
+
const shutdown = new AbortController(); let closing = false, closeTask;
|
|
491
|
+
const track = promise => { active.add(promise); void promise.then(() => active.delete(promise), () => active.delete(promise)); return promise; };
|
|
492
|
+
function finish(token, error) {
|
|
493
|
+
if (token.finished) return; token.finished = true;
|
|
494
|
+
token.nativeSignal.removeEventListener('abort', token.abort);
|
|
495
|
+
later(() => { hooks.settled(token.choice, error); tokens.delete(token.nativeSignal); token.done(); });
|
|
496
|
+
}
|
|
497
|
+
function tokenFor(nativeSignal) {
|
|
498
|
+
if (tokens.has(nativeSignal)) return tokens.get(nativeSignal);
|
|
499
|
+
const controller = new AbortController(); let done;
|
|
500
|
+
const token = { nativeSignal, controller, signal: AbortSignal.any([nativeSignal, controller.signal, shutdown.signal]), entered: false, finished: false, donePromise: new Promise(resolve => { done = resolve; }), done: () => done(), choice: undefined, abort: () => {} };
|
|
501
|
+
token.choice = hooks.capture(token.signal, controller);
|
|
502
|
+
token.abort = () => finish(token, new WorktreeError('cancelled', 'native-attempt-cancelled'));
|
|
503
|
+
nativeSignal.addEventListener('abort', token.abort, { once: true }); tokens.set(nativeSignal, token);
|
|
504
|
+
if (nativeSignal.aborted) token.abort();
|
|
505
|
+
return token;
|
|
506
|
+
}
|
|
507
|
+
async function adapter(text, ids, mode, nativeSignal) {
|
|
508
|
+
let token;
|
|
509
|
+
try {
|
|
510
|
+
token = tokenFor(nativeSignal); token.entered = true;
|
|
511
|
+
if (closing || token.signal.aborted) throw new WorktreeError('cancelled', 'cancelled');
|
|
512
|
+
if (token.choice.mode === 'local') { await hooks.beforeLocal?.(ids, token.signal); return await track(interrupted(original.call(deps, text, ids, mode, token.signal), token.signal)); }
|
|
513
|
+
return await track(hooks.submit(token.choice, text, ids, mode, token.signal));
|
|
514
|
+
} catch (error) { return { kind: 'error', text: hooks.error(error) }; }
|
|
515
|
+
finally { if (token) finish(token); }
|
|
516
|
+
}
|
|
517
|
+
const controllers = new WeakMap();
|
|
518
|
+
function triggerAdapter() {
|
|
519
|
+
const controller = originalTriggers?.call(deps);
|
|
520
|
+
if (!controller) return controller;
|
|
521
|
+
if (controllers.has(controller)) return controllers.get(controller);
|
|
522
|
+
const bound = new Map();
|
|
523
|
+
const proxy = new Proxy(controller, { get(target, key) {
|
|
524
|
+
if (key === 'adjudicate' || key === 'serializeReference') {
|
|
525
|
+
if (!bound.has(key)) bound.set(key, async (...args) => {
|
|
526
|
+
let token;
|
|
527
|
+
try {
|
|
528
|
+
const signal = key === 'adjudicate' ? args[1] : args[2]; token = tokenFor(signal);
|
|
529
|
+
const next = [...args]; next[key === 'adjudicate' ? 1 : 2] = token.signal;
|
|
530
|
+
const result = await track(interrupted(Reflect.apply(Reflect.get(target, key, target), target, next), next[key === 'adjudicate' ? 1 : 2]));
|
|
531
|
+
if (key === 'adjudicate' && result !== undefined) finish(token);
|
|
532
|
+
return result;
|
|
533
|
+
} catch (error) { if (token) { token.controller.abort(); finish(token, error); } throw error; }
|
|
534
|
+
});
|
|
535
|
+
return bound.get(key);
|
|
536
|
+
}
|
|
537
|
+
const value = Reflect.get(target, key, target);
|
|
538
|
+
if (typeof value !== 'function') return value;
|
|
539
|
+
if (!bound.has(key)) bound.set(key, value.bind(target)); return bound.get(key);
|
|
540
|
+
} });
|
|
541
|
+
controllers.set(controller, proxy); return proxy;
|
|
542
|
+
}
|
|
543
|
+
Object.defineProperty(deps, 'defaultSink', { ...sink, value: adapter });
|
|
544
|
+
if (triggers) Object.defineProperty(deps, 'inputTriggers', { ...triggers, value: triggerAdapter });
|
|
545
|
+
return { abi: NATIVE_SUBMIT_ABI,
|
|
546
|
+
cancelNew() { for (const token of tokens.values()) if (token.choice.mode === 'new') token.controller.abort(); },
|
|
547
|
+
close() {
|
|
548
|
+
if (closeTask) return closeTask; closing = true; shutdown.abort();
|
|
549
|
+
closeTask = (async () => {
|
|
550
|
+
await Promise.allSettled([...active]);
|
|
551
|
+
await Promise.resolve(); await Promise.resolve();
|
|
552
|
+
// A cancelled codec/adjudicator must settle natively before restoring
|
|
553
|
+
// callbacks; otherwise its late sink could accidentally send Local.
|
|
554
|
+
await Promise.allSettled([...tokens.values()].map(token => token.donePromise));
|
|
555
|
+
if (own(input, 'deps')?.value === deps && own(deps, 'defaultSink')?.value === adapter) Object.defineProperty(deps, 'defaultSink', sink);
|
|
556
|
+
if (triggers && own(input, 'deps')?.value === deps && own(deps, 'inputTriggers')?.value === triggerAdapter) Object.defineProperty(deps, 'inputTriggers', triggers);
|
|
557
|
+
})(); return closeTask;
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function setupProgress(data, operationId, previous = 0) {
|
|
562
|
+
const stages = ['fetching', 'creating', 'naming', 'opening', 'ready'];
|
|
563
|
+
if (!data || data.operationId !== operationId || !Number.isSafeInteger(data.revision) || data.revision <= previous || typeof data.terminal !== 'boolean' || !Array.isArray(data.stages) || data.stages.length > 8 || !data.stages.every(item => item && stages.includes(item.stage) && item.operationId === operationId && (item.worktreeId === undefined || uuid(item.worktreeId)))) throw new WorktreeError('decode', 'invalid-progress');
|
|
564
|
+
return data;
|
|
565
|
+
}
|
|
566
|
+
async function prepareWorktree(ctx, actorId, request, signal, onProgress) {
|
|
567
|
+
const watching = new AbortController(), watchSignal = AbortSignal.any([signal, watching.signal]);
|
|
568
|
+
const progress = (async () => {
|
|
569
|
+
let revision = 0;
|
|
570
|
+
for (;;) {
|
|
571
|
+
const data = setupProgress(await quietRPC(ctx, 'dsh-worktrees/progress', { v: 1, actorId, operationId: request.operationId, after: revision }, watchSignal), request.operationId, revision);
|
|
572
|
+
revision = data.revision; onProgress(data.stages);
|
|
573
|
+
if (data.terminal) return;
|
|
574
|
+
}
|
|
575
|
+
})();
|
|
576
|
+
let progressFailure;
|
|
577
|
+
void progress.catch(error => { progressFailure = error; watching.abort(); });
|
|
578
|
+
try {
|
|
579
|
+
const result = validateData(request, await quietRPC(ctx, 'dsh-worktrees/prepare', { v: 1, actorId, request }, signal));
|
|
580
|
+
await progress;
|
|
581
|
+
if (progressFailure) throw progressFailure;
|
|
582
|
+
return result;
|
|
583
|
+
} finally { watching.abort(); await Promise.allSettled([progress]); }
|
|
584
|
+
}
|
|
585
|
+
function waitForDraftFiles(conversation, ids, signal) {
|
|
586
|
+
const descriptors = conversation.resolveDraftAttachments(ids);
|
|
587
|
+
if (descriptors.length !== ids.length) return Promise.reject(new WorktreeError('filesLost', 'draft-files-unavailable'));
|
|
588
|
+
const files = descriptors.filter(item => item.kind === 'file');
|
|
589
|
+
return new Promise((resolve, reject) => {
|
|
590
|
+
let unsubscribe = () => {}, done = false;
|
|
591
|
+
const finish = error => { if (done) return; done = true; unsubscribe(); signal.removeEventListener('abort', abort); error ? reject(error) : resolve(); };
|
|
592
|
+
const abort = () => finish(new WorktreeError('cancelled', 'cancelled'));
|
|
593
|
+
const check = () => {
|
|
594
|
+
const uploads = conversation.fileUploads.getSnapshot();
|
|
595
|
+
const missing = files.find(item => !uploads[item.id]), failed = files.find(item => uploads[item.id]?.status === 'error');
|
|
596
|
+
if (missing) finish(new WorktreeError('filesLost', 'file-upload-unavailable'));
|
|
597
|
+
else if (failed) finish(new WorktreeError('upload', 'file-upload-failed', uploads[failed.id].message));
|
|
598
|
+
else if (files.every(item => uploads[item.id]?.status === 'ready')) finish();
|
|
599
|
+
};
|
|
600
|
+
unsubscribe = conversation.fileUploads.subscribe(check); signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort(); else check();
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function createRepositoryAvailability(ctx, projects) {
|
|
604
|
+
const listeners = new Set(); let disposed = false, started = false, observedSession, active, version = 0, stopBinding = () => {}, queued = false, connectionId = ctx.connection.generation.getSnapshot()?.id;
|
|
605
|
+
let state = { target: null, phase: 'idle', available: false };
|
|
606
|
+
const publish = patch => { if (disposed) return; state = { ...state, ...patch }; for (const listener of [...listeners]) listener(); };
|
|
607
|
+
const store = { getSnapshot: () => state, subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
608
|
+
function target(sessionId) {
|
|
609
|
+
if (!sessionId || projectActor(ctx.sessions.list.getSnapshot()) !== sessionId) return null;
|
|
610
|
+
const binding = ctx.sessions.binding(sessionId), connection = ctx.connection.generation.getSnapshot();
|
|
611
|
+
if (!binding || !connection || !sourceEligible(binding.session.getSnapshot())) return null;
|
|
612
|
+
const project = captureProject(projects.context(sessionId));
|
|
613
|
+
return { sessionId, binding, connection: connection.id, project, path: project?.path };
|
|
614
|
+
}
|
|
615
|
+
const same = (left, right) => !!left && !!right && left.sessionId === right.sessionId && left.binding === right.binding && left.connection === right.connection && sameProject(left.project, right.project);
|
|
616
|
+
function reset() { active?.controller.abort(); active = undefined; ++version; stopBinding(); stopBinding = () => {}; publish({ target: null, phase: 'idle', available: false }); }
|
|
617
|
+
function schedule() { if (!started || queued || disposed) return; queued = true; void Promise.resolve().then(() => { queued = false; if (!disposed) void ensure(projectActor(ctx.sessions.list.getSnapshot())); }); }
|
|
618
|
+
function changed() { const current = target(projectActor(ctx.sessions.list.getSnapshot())); if (state.target && !same(current, state.target)) reset(); if (current && !state.target) schedule(); }
|
|
619
|
+
function ensure(sessionId) {
|
|
620
|
+
if (disposed) return Promise.resolve(); started = true; observedSession = sessionId;
|
|
621
|
+
const next = target(sessionId);
|
|
622
|
+
if (!next) { if (state.target) reset(); return Promise.resolve(); }
|
|
623
|
+
if (same(next, state.target) && state.phase !== 'idle') return active?.task || Promise.resolve();
|
|
624
|
+
reset(); const controller = new AbortController(), seq = ++version;
|
|
625
|
+
const operation = { controller, task: null }; active = operation;
|
|
626
|
+
stopBinding = next.binding.session.subscribe(changed);
|
|
627
|
+
operation.task = Promise.resolve().then(async () => {
|
|
628
|
+
if (disposed || controller.signal.aborted || seq !== version || !same(target(sessionId), next)) return;
|
|
629
|
+
try {
|
|
630
|
+
// Read-only local discovery through the existing caller-authorized status operation.
|
|
631
|
+
const data = await requestHost(ctx, sessionId, { action: 'status', ...(next.path ? { repoPath: next.path } : {}) }, controller.signal);
|
|
632
|
+
if (next.path && data.projectPath !== next.path) throw new WorktreeError('decode', 'repository-target-mismatch');
|
|
633
|
+
if (!disposed && !controller.signal.aborted && seq === version && same(target(sessionId), next)) publish({ phase: data.remotes.length ? 'ready' : 'unavailable', available: data.remotes.length > 0 });
|
|
634
|
+
} catch { if (!disposed && !controller.signal.aborted && seq === version && same(target(sessionId), next)) publish({ phase: 'unavailable', available: false }); }
|
|
635
|
+
finally { if (active === operation) active = undefined; }
|
|
636
|
+
});
|
|
637
|
+
publish({ target: next, phase: 'checking', available: false });
|
|
638
|
+
return operation.task;
|
|
639
|
+
}
|
|
640
|
+
const subscriptions = [ctx.sessions.list.subscribe(changed), projects.store.subscribe(changed), ctx.connection.generation.subscribe(() => { const next = ctx.connection.generation.getSnapshot()?.id; if (next === connectionId) return; connectionId = next; reset(); schedule(); })];
|
|
641
|
+
return { store, ensure,
|
|
642
|
+
withdraw(sessionId) { if (observedSession === sessionId) { started = false; observedSession = undefined; reset(); } },
|
|
643
|
+
unavailable(sessionId, binding, project) { const current = target(sessionId); if (disposed || !same(current, state.target) || current.binding !== binding || !sameProject(current.project, project)) return; active?.controller.abort(); active = undefined; ++version; publish({ phase: 'unavailable', available: false }); },
|
|
644
|
+
enabled(sessionId) { return !disposed && state.phase === 'ready' && state.available && same(target(sessionId), state.target); },
|
|
645
|
+
dispose() { if (disposed) return; active?.controller.abort(); ++version; stopBinding(); subscriptions.forEach(stop => stop()); disposed = true; listeners.clear(); },
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function createWorktreeFlow(ctx, t, projects, repositories) {
|
|
649
|
+
const conversation = ctx.get('conversation'), listeners = new Set(), leases = new Map(), operations = new Set(), restaged = new Set();
|
|
650
|
+
let state = { sessionId: undefined, mode: 'local', sourceChanged: false, busy: false, loading: false, error: '', status: null, remote: '', branch: '', branches: null, query: '', steps: [], phase: '', op: null };
|
|
651
|
+
let query, selectedSource, generation = 0, disposed = false;
|
|
652
|
+
const publish = patch => { if (disposed) return; state = { ...state, ...patch }; for (const listener of [...listeners]) listener(); };
|
|
653
|
+
const store = { getSnapshot: () => state, subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
654
|
+
const pending = id => ctx.uiSession.sessionStatus.getSnapshot().get(id)?.pendingInteraction !== undefined;
|
|
655
|
+
const repositoryAllowed = id => repositories?.enabled(id) === true;
|
|
656
|
+
function requireRepository(sessionId) { if (!repositoryAllowed(sessionId)) throw new WorktreeError('repoUnavailable', 'repository-not-verified'); }
|
|
657
|
+
async function repositoryStatus(sessionId, binding, project, signal) {
|
|
658
|
+
try {
|
|
659
|
+
const data = await requestHost(ctx, sessionId, { action: 'status', ...(project ? { repoPath: project.path } : {}) }, signal);
|
|
660
|
+
if (project && data.projectPath !== project.path) throw new WorktreeError('decode', 'repository-target-mismatch');
|
|
661
|
+
if (!data.remotes.length) throw new WorktreeError('noRemote', 'no-remote');
|
|
662
|
+
return data;
|
|
663
|
+
} catch (error) { if (!signal.aborted) repositories?.unavailable(sessionId, binding, project); throw error; }
|
|
664
|
+
}
|
|
665
|
+
function source(sessionId) {
|
|
666
|
+
const binding = ctx.sessions.binding(sessionId), input = binding && conversation.input.for(binding.ctx);
|
|
667
|
+
if (!binding || projectActor(ctx.sessions.list.getSnapshot()) !== sessionId || !sourceEligible(binding.session.getSnapshot()) || pending(sessionId)) throw new WorktreeError('changed', 'source-not-blank-idle');
|
|
668
|
+
return { binding, input, project: captureProject(projects.context(sessionId)) };
|
|
669
|
+
}
|
|
670
|
+
function requireIntent(sessionId, current) {
|
|
671
|
+
if (state.sessionId === sessionId && state.mode === 'new' && (state.sourceChanged || !selectedSource || selectedSource.binding !== current.binding || !sameProject(selectedSource.project, current.project))) throw new WorktreeError('projectChanged', 'selected-worktree-source-changed');
|
|
672
|
+
}
|
|
673
|
+
function checkSource(sessionId, before) {
|
|
674
|
+
const current = source(sessionId); requireRepository(sessionId); requireIntent(sessionId, current);
|
|
675
|
+
if (current.binding !== before.binding || !sameProject(current.project, before.project)) throw new WorktreeError('changed', 'source-changed');
|
|
676
|
+
}
|
|
677
|
+
function check(op) {
|
|
678
|
+
if (disposed || op.signal.aborted || op.navigation.aborted) throw new WorktreeError('cancelled', 'cancelled');
|
|
679
|
+
const current = source(op.sessionId); requireRepository(op.sessionId);
|
|
680
|
+
if (current.binding !== op.binding || !sameProject(current.project, op.project)) throw new WorktreeError('changed', 'source-changed');
|
|
681
|
+
if (op.block.store.getSnapshot() !== op.block.owned) throw new WorktreeError('ownership', 'block-lost');
|
|
682
|
+
}
|
|
683
|
+
function capture(sessionId, binding, signal, controller) {
|
|
684
|
+
if (state.sessionId !== sessionId || state.mode !== 'new') return { mode: 'local' };
|
|
685
|
+
if (state.busy) throw new WorktreeError('busy', 'first-message-already-preparing');
|
|
686
|
+
requireRepository(sessionId);
|
|
687
|
+
const captured = source(sessionId); requireIntent(sessionId, captured); if (captured.binding !== binding) throw new WorktreeError('changed', 'binding-changed');
|
|
688
|
+
const old = state.op;
|
|
689
|
+
if (old && !old.retryable) throw new WorktreeError('recovery', 'check-created-conversation-before-retry');
|
|
690
|
+
const intent = Object.freeze({ remote: state.remote, branch: state.branch, branches: state.branches && Object.freeze({ ...state.branches, items: Object.freeze(state.branches.items.map(item => Object.freeze({ ...item }))) }) });
|
|
691
|
+
query?.abort(); ++generation;
|
|
692
|
+
const navigation = ctx.layout.beginNavigation();
|
|
693
|
+
const op = { ...captured, intent, mode: 'new', id: old?.id || crypto.randomUUID(), sessionId, controller, signal, navigation, result: old?.result || null, request: old?.request || null, retryable: false, admission: 'none', reference: null, sourceReference: null, staged: false, cleanup: () => {} };
|
|
694
|
+
op.sourceReference = ctx.sessions.retain(sessionId, { source: 'controllerOperation' });
|
|
695
|
+
try { op.block = acquireBlock(conversation.blocks, sessionId, t('preparingMessage')); }
|
|
696
|
+
catch (error) { op.sourceReference.release(); throw error; }
|
|
697
|
+
const cancel = () => controller.abort(), changed = () => { if (op.committing) return; try { check(op); } catch { cancel(); } };
|
|
698
|
+
const subscriptions = [ctx.sessions.list, binding.session, projects.store, ctx.uiSession.sessionStatus, op.block.store].map(item => item.subscribe(changed));
|
|
699
|
+
navigation.addEventListener('abort', cancel, { once: true });
|
|
700
|
+
op.cleanup = () => { navigation.removeEventListener('abort', cancel); subscriptions.splice(0).forEach(remove => remove()); op.block.release(); };
|
|
701
|
+
operations.add(op); publish({ op, busy: true, loading: false, error: '', steps: [], phase: 'preparing' }); return op;
|
|
702
|
+
}
|
|
703
|
+
function settled(op, preflightError) {
|
|
704
|
+
if (op?.mode !== 'new') return;
|
|
705
|
+
if (!op.sinkEntered) { op.retryable = true; publish({ busy: false, op: null, phase: '', error: preflightError ? report(t, preflightError) : '' }); }
|
|
706
|
+
op.cleanup(); operations.delete(op);
|
|
707
|
+
if (op.sourceReference) { if (disposed || op.accepted) op.sourceReference.release(); else releaseAfterMainOwnership(ctx, op.sourceReference, true); op.sourceReference = null; }
|
|
708
|
+
if (op.reference) { if (disposed) op.reference.release(); else releaseAfterMainOwnership(ctx, op.reference, !!op.accepted); op.reference = null; }
|
|
709
|
+
}
|
|
710
|
+
async function submit(op, text, ids, mode, signal) {
|
|
711
|
+
op.sinkEntered = true; op.text = text; op.ids = [...ids];
|
|
712
|
+
try {
|
|
713
|
+
check(op);
|
|
714
|
+
if (!text.trim() && !ids.length) { publish({ busy: false, phase: '', op: null }); return { kind: 'success' }; }
|
|
715
|
+
publish({ phase: 'fetching' });
|
|
716
|
+
if (!op.result) {
|
|
717
|
+
const status = await repositoryStatus(op.sessionId, op.binding, op.project, signal); check(op);
|
|
718
|
+
const remote = op.intent.remote || (status.remotes.some(item => item.name === status.defaults?.remote) ? status.defaults.remote : status.remotes[0]?.name || '');
|
|
719
|
+
if (!remote) throw new WorktreeError('noRemote', 'no-remote');
|
|
720
|
+
const identity = status.remotes.find(item => item.name === remote)?.identity;
|
|
721
|
+
const advertised = op.intent.branches && op.intent.remote === remote ? op.intent.branches : await requestHost(ctx, op.sessionId, { action: 'branches', repoPath: projectPath(status, op.project), remote, remoteIdentity: identity, limit: 100 }, signal); check(op);
|
|
722
|
+
const branch = op.intent.branch || advertisedDefault(advertised, status.defaults?.branch); if (!branch) throw new WorktreeError('noRemote', 'select-remote-base');
|
|
723
|
+
op.request = Object.freeze({ action: 'create', operationId: op.id, repoPath: projectPath(status, op.project), remote, remoteIdentity: advertised.remoteIdentity, remoteBranch: branch, sourceSessionId: op.sessionId, sessionMode: 'new', requireBlankSource: true, settingsHash: status.settingsHash, firstPrompt: text, ...(op.project ? { projectId: op.project.projectId, folderId: op.project.folderId } : {}) });
|
|
724
|
+
op.result = await prepareWorktree(ctx, op.sessionId, op.request, signal, steps => publish({ steps, phase: steps.at(-1)?.stage || 'fetching' })); check(op);
|
|
725
|
+
} else if (op.request?.firstPrompt !== text) throw new WorktreeError('changed', 'retry-message-changed');
|
|
726
|
+
const result = op.result;
|
|
727
|
+
if (result.sessionId === op.sessionId || result.worktree.state !== 'ready' || !result.worktree.branch) throw new WorktreeError('invalidSession', 'invalid-prepared-target');
|
|
728
|
+
projects.rememberWorktree(result, op.project); check(op);
|
|
729
|
+
op.reference = ctx.sessions.retain(result.sessionId, { source: 'controllerOperation' });
|
|
730
|
+
const target = await projectReady(op.reference, signal); check(op);
|
|
731
|
+
if (!sourceEligible(target.session.getSnapshot()) || pending(result.sessionId) || !inputEmpty(conversation.input.for(target.ctx).state.getSnapshot()) || conversation.blocks.storeFor(result.sessionId).getSnapshot() !== undefined) throw new WorktreeError('targetBusy', 'target-not-empty');
|
|
732
|
+
publish({ phase: 'attachments' });
|
|
733
|
+
if (ids.length) { conversation.rebindDraftFiles(result.sessionId, ids); op.staged = true; await waitForDraftFiles(conversation, ids, signal); }
|
|
734
|
+
check(op);
|
|
735
|
+
const settings = await requestHost(ctx, op.sessionId, { action: 'status', operationId: op.id }, signal); check(op);
|
|
736
|
+
if (settings.settingsHash !== result.settingsHash) throw new WorktreeError('settings', 'settings-changed-before-send');
|
|
737
|
+
publish({ phase: 'sending' }); op.admission = 'pending';
|
|
738
|
+
let outcome;
|
|
739
|
+
try { outcome = await conversation.sendSession(target.session, text, ids, mode, signal); }
|
|
740
|
+
catch (error) { op.admission = 'unknown'; throw new WorktreeError('uncertain', 'check-destination-before-resending', error?.message || ''); }
|
|
741
|
+
if (outcome.kind !== 'success') {
|
|
742
|
+
const snapshot = target.session.getSnapshot(), code = snapshot.promptError?.op === 'send' && snapshot.promptError.error?.code;
|
|
743
|
+
op.admission = typeof code === 'string' && !code.startsWith('gateway/') && snapshot.blank && !snapshot.running && !snapshot.pendingSubmissions.length ? 'refused' : 'unknown';
|
|
744
|
+
op.retryable = op.admission === 'refused'; throw new WorktreeError(op.retryable ? 'host' : 'uncertain', 'prompt-' + op.admission, outcome.text || '');
|
|
745
|
+
}
|
|
746
|
+
op.admission = 'success'; op.accepted = true; op.committing = true;
|
|
747
|
+
publish({ busy: false, phase: 'complete', mode: 'local', op: null, error: '' });
|
|
748
|
+
later(() => { if (!disposed && !op.navigation.aborted && projectActor(ctx.sessions.list.getSnapshot()) === op.sessionId) { op.cleanup(); ctx.uiWorkspace.openSession(result.sessionId); } });
|
|
749
|
+
return outcome;
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (op.staged && !op.accepted) { conversation.rebindDraftFiles(op.sessionId, ids); ids.forEach(id => restaged.add(id)); }
|
|
752
|
+
if (op.admission === 'none' && !op.request) op.retryable = true;
|
|
753
|
+
publish({ busy: false, failedAt: state.phase, phase: 'failed', error: report(t, error), ...(op.retryable && !op.result ? { op: null } : {}) });
|
|
754
|
+
return { kind: 'error', text: report(t, error) };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function observe(sessionId) {
|
|
758
|
+
const binding = ctx.sessions.binding(sessionId); if (!binding || leases.has(binding)) return;
|
|
759
|
+
const input = conversation.input.for(binding.ctx);
|
|
760
|
+
const lease = leaseNativeSink(input, binding, { capture: (signal, controller) => capture(sessionId, binding, signal, controller), submit, settled, beforeLocal: async (ids, signal) => { if (ids.some(id => restaged.has(id))) { await waitForDraftFiles(conversation, ids, signal); ids.forEach(id => restaged.delete(id)); } }, error: error => report(t, error) });
|
|
761
|
+
leases.set(binding, lease);
|
|
762
|
+
const stop = binding.ctx.effect(() => async () => { try { await lease.close(); } finally { leases.delete(binding); } });
|
|
763
|
+
lease.stopScope = stop;
|
|
764
|
+
}
|
|
765
|
+
function select(sessionId, mode) {
|
|
766
|
+
if (disposed || !['local', 'new', 'worktree'].includes(mode)) return;
|
|
767
|
+
const latchedNew = state.sessionId === sessionId && state.mode === 'new', keepCurrent = mode === 'worktree';
|
|
768
|
+
try {
|
|
769
|
+
const current = source(sessionId); if (mode === 'new') requireRepository(sessionId); observe(sessionId);
|
|
770
|
+
if (keepCurrent) {
|
|
771
|
+
if (projectBacking(projects.store.getSnapshot(), ctx.workspaces.list.getSnapshot(), sessionId).mode !== 'worktree') throw new WorktreeError('changed', 'current-folder-not-worktree');
|
|
772
|
+
mode = 'local'; // Native sink, same checkout; never navigate to a Local source folder.
|
|
773
|
+
}
|
|
774
|
+
if (mode === 'local' && !keepCurrent && projects.context(sessionId)?.binding.mode === 'worktree') {
|
|
775
|
+
if (!inputEmpty(current.input.state.getSnapshot())) throw new WorktreeError('draftWarning', 'keep-existing-draft');
|
|
776
|
+
for (const lease of leases.values()) lease.cancelNew(); ctx.uiWorkspace.startSession(current.project.folderId); return;
|
|
777
|
+
}
|
|
778
|
+
if (mode === 'local') { for (const lease of leases.values()) lease.cancelNew(); }
|
|
779
|
+
const changed = state.sessionId !== sessionId || state.sourceChanged || selectedSource?.binding !== current.binding || !sameProject(selectedSource?.project, current.project);
|
|
780
|
+
if (changed) { query?.abort(); ++generation; }
|
|
781
|
+
selectedSource = { sessionId, binding: current.binding, project: current.project };
|
|
782
|
+
publish({ sessionId, mode, sourceChanged: false, error: '', ...(changed ? { status: null, branches: null, branch: '', remote: '', ...(state.sessionId === sessionId ? {} : { op: null, steps: [], phase: '' }) } : {}) });
|
|
783
|
+
} catch (error) { publish({ sessionId, mode: latchedNew ? 'new' : 'local', error: report(t, error) }); }
|
|
784
|
+
}
|
|
785
|
+
async function loadBranches(sessionId, text = '', more = false) {
|
|
786
|
+
if (disposed || state.busy || state.sessionId !== sessionId || !state.status || !state.remote || (more && !state.branches?.nextCursor)) return;
|
|
787
|
+
let current;
|
|
788
|
+
try { current = source(sessionId); requireRepository(sessionId); requireIntent(sessionId, current); } catch (error) { publish({ error: report(t, error) }); return; }
|
|
789
|
+
query?.abort(); query = new AbortController(); const controller = query, seq = ++generation, before = state.branches, remote = state.remote, project = current.project;
|
|
790
|
+
publish({ loading: true, query: text, ...(more ? {} : { branches: null, branch: '' }) });
|
|
791
|
+
try {
|
|
792
|
+
const data = await requestHost(ctx, sessionId, { action: 'branches', repoPath: projectPath(state.status, project), remote, remoteIdentity: state.status.remotes.find(item => item.name === remote)?.identity, query: text, ...(more ? { cursor: before.nextCursor } : {}), limit: 100 }, controller.signal);
|
|
793
|
+
if (disposed || controller.signal.aborted || seq !== generation) return; checkSource(sessionId, current);
|
|
794
|
+
const names = new Set(more ? before.items.map(item => item.name) : []), branches = { ...data, items: [...(more ? before.items : []), ...data.items.filter(item => !names.has(item.name))] };
|
|
795
|
+
publish({ branches, branch: more ? state.branch : text ? '' : advertisedDefault(branches, state.status.defaults?.branch), loading: false });
|
|
796
|
+
} catch (error) { if (!controller.signal.aborted && seq === generation) publish({ loading: false, error: report(t, error) }); }
|
|
797
|
+
}
|
|
798
|
+
async function configure(sessionId) {
|
|
799
|
+
if (disposed || state.busy) return;
|
|
800
|
+
let current;
|
|
801
|
+
try { current = source(sessionId); requireRepository(sessionId); requireIntent(sessionId, current); } catch (error) { publish({ error: report(t, error) }); return; }
|
|
802
|
+
query?.abort(); query = new AbortController(); const controller = query, seq = ++generation, project = current.project;
|
|
803
|
+
if (state.sessionId !== sessionId || state.mode !== 'new') selectedSource = { sessionId, binding: current.binding, project };
|
|
804
|
+
publish({ sessionId, loading: true, status: null, branches: null, branch: '', remote: '', error: '', ...(state.sessionId === sessionId ? {} : { mode: 'local', sourceChanged: false, op: null, steps: [], phase: '' }) });
|
|
805
|
+
try {
|
|
806
|
+
const status = await repositoryStatus(sessionId, current.binding, project, controller.signal);
|
|
807
|
+
if (disposed || controller.signal.aborted || seq !== generation) return; checkSource(sessionId, current);
|
|
808
|
+
const remote = status.remotes.some(item => item.name === status.defaults?.remote) ? status.defaults.remote : status.remotes[0]?.name || '';
|
|
809
|
+
publish({ status, remote, loading: false }); if (remote) await loadBranches(sessionId);
|
|
810
|
+
} catch (error) { if (!controller.signal.aborted && seq === generation) publish({ loading: false, error: report(t, error) }); }
|
|
811
|
+
}
|
|
812
|
+
async function reconcile(sessionId) {
|
|
813
|
+
const op = state.sessionId === sessionId && state.op; if (!op || state.busy) return;
|
|
814
|
+
try {
|
|
815
|
+
const data = await requestHost(ctx, sessionId, { action: 'status', operationId: op.id });
|
|
816
|
+
if (data.worktree) projects.rememberRecords([data.worktree]);
|
|
817
|
+
if (data.operation.phase === 'ready' && data.sessionId && data.worktree) { op.result = { ...data, workspaceId: data.operation.workspaceId, settingsHash: data.operation.settingsHash }; op.retryable = op.admission === 'none' || op.admission === 'refused'; }
|
|
818
|
+
publish({ error: data.operation.error?.message || t('retained') });
|
|
819
|
+
} catch (error) { publish({ error: report(t, error), ...(error?.kind === 'host' && error.code === 'NOT_FOUND' ? { op: null } : {}) }); }
|
|
820
|
+
}
|
|
821
|
+
const repositoryChanged = repositories?.store.subscribe(() => {
|
|
822
|
+
if (!state.sessionId || repositoryAllowed(state.sessionId)) return;
|
|
823
|
+
query?.abort(); ++generation;
|
|
824
|
+
if (state.mode === 'new') for (const lease of leases.values()) lease.cancelNew();
|
|
825
|
+
publish({ loading: false, status: null, branches: null, remote: '', branch: '', ...(state.mode === 'new' ? { error: t('repoUnavailable') } : {}) });
|
|
826
|
+
});
|
|
827
|
+
function selectionChanged() {
|
|
828
|
+
if (!selectedSource || state.sessionId !== selectedSource.sessionId || (state.mode === 'new' && state.sourceChanged)) return;
|
|
829
|
+
const binding = ctx.sessions.binding(state.sessionId), project = captureProject(projects.context(state.sessionId));
|
|
830
|
+
if (binding === selectedSource.binding && sameProject(project, selectedSource.project)) return;
|
|
831
|
+
query?.abort(); ++generation;
|
|
832
|
+
if (state.mode === 'new') for (const lease of leases.values()) lease.cancelNew();
|
|
833
|
+
else selectedSource = { sessionId: state.sessionId, binding, project };
|
|
834
|
+
publish({ loading: false, status: null, branches: null, query: '', remote: '', branch: '', ...(state.mode === 'new' ? { sourceChanged: true, error: t('projectChanged') } : {}) });
|
|
835
|
+
}
|
|
836
|
+
const projectChanged = projects.store.subscribe(selectionChanged);
|
|
837
|
+
const unselect = ctx.sessions.list.subscribe(() => { for (const op of operations) if (!op.committing && projectActor(ctx.sessions.list.getSnapshot()) !== op.sessionId) op.controller.abort(); selectionChanged(); });
|
|
838
|
+
const reconnect = ctx.connection.generation.subscribe(() => { query?.abort(); ++generation; for (const lease of leases.values()) lease.cancelNew(); publish({ loading: false, status: null, branches: null, branch: '', remote: '' }); });
|
|
839
|
+
return { store, observe, select, configure, loadBranches, reconcile,
|
|
840
|
+
cancel() { for (const lease of leases.values()) lease.cancelNew(); },
|
|
841
|
+
setRemote(sessionId, remote) { if (!state.busy && !state.sourceChanged && state.sessionId === sessionId && repositoryAllowed(sessionId) && state.status?.remotes.some(item => item.name === remote)) { publish({ remote, query: '' }); void loadBranches(sessionId); } },
|
|
842
|
+
setBranch(branch) { if (!state.busy && !state.sourceChanged && repositoryAllowed(state.sessionId) && state.branches?.items.some(item => item.name === branch)) publish({ branch }); },
|
|
843
|
+
openTarget(sessionId) { const op = state.sessionId === sessionId && state.op; if (op?.result?.sessionId) ctx.uiWorkspace.openSession(op.result.sessionId); },
|
|
844
|
+
async dispose() { disposed = true; query?.abort(); ++generation; repositoryChanged?.(); projectChanged(); unselect(); reconnect(); for (const op of operations) op.controller.abort(); await Promise.allSettled([...leases.values()].map(lease => lease.close())); for (const lease of leases.values()) lease.stopScope?.(); leases.clear(); selectedSource = undefined; },
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
const projectSlotName = key => PANEL + '.projects.' + key;
|
|
848
|
+
const projectSlots = ['sidebar.workspaces.directoryFlow', 'sidebar.workspaces.session.menu.item', 'sidebar.workspaces.session.row.action', 'sidebar.session.row.leading', 'sidebar.session.row.hover'];
|
|
849
|
+
function projectChildren(ctx) {
|
|
850
|
+
return Object.fromEntries(projectSlots.map(key => [projectSlotName(key), { kind: key.endsWith('directoryFlow') ? 'single' : 'list', scope: 'root', ...(key.endsWith('menu.item') ? { inject: { hooks: { menuOpenState: (_standard, state) => () => state, shortcuts: ctx.shortcuts.catalog } } } : {}) }]));
|
|
851
|
+
}
|
|
852
|
+
// SlotRegistry.entriesOfSlot/subscribe/inject are PUBLIC SDK methods. The
|
|
853
|
+
// native browser stays registered, and owns its original child declarations.
|
|
854
|
+
// Only plugin-owned aliases are declared/mirrored; unloading never clears or
|
|
855
|
+
// redeclares installed extension seats. Copy their inject/locale/store faces,
|
|
856
|
+
// not DOM or private components from a Harness module import.
|
|
857
|
+
function mirrorProjectSlot(ctx, source, destination = projectSlotName(source), mapKey = projectSlotName) {
|
|
858
|
+
return ctx.slots.inject(destination, () => ctx.slots.inject(source, () => {
|
|
859
|
+
const copies = new Map();
|
|
860
|
+
const clear = () => { for (const dispose of copies.values()) dispose(); copies.clear(); };
|
|
861
|
+
const synchronize = () => {
|
|
862
|
+
const entries = ctx.slots.entriesOfSlot(source);
|
|
863
|
+
for (const [entry, dispose] of copies) if (!entries.includes(entry)) { dispose(); copies.delete(entry); }
|
|
864
|
+
for (const entry of entries) {
|
|
865
|
+
if (copies.has(entry)) continue;
|
|
866
|
+
const children = entry.children && Object.fromEntries(Object.entries(entry.children).map(([key, spec]) => [mapKey(key), spec]));
|
|
867
|
+
const component = children ? props => h(entry.component, { ...props, renderSlot: (key, owner, options) => props.renderSlot(mapKey(key), owner, options), ...(props.renderSlotChain ? { renderSlotChain: (key, owner, options) => props.renderSlotChain(mapKey(key), owner, options) } : {}) }) : entry.component;
|
|
868
|
+
const remove = ctx.slots.register({ ...entry.options, name: destination, ...(entry.inject ? { inject: entry.inject } : {}), ...(entry.locale ? { locale: entry.locale } : {}), ...(entry.store ? { store: entry.store } : {}), ...(entry.select ? { select: entry.select } : {}), ...(children ? { children } : {}) }, component);
|
|
869
|
+
const nested = Object.keys(entry.children || {}).map(key => mirrorProjectSlot(ctx, key, mapKey(key), mapKey));
|
|
870
|
+
copies.set(entry, () => { for (const dispose of nested) dispose(); remove(); });
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
synchronize(); const unsubscribe = ctx.slots.subscribe(source, synchronize);
|
|
874
|
+
return () => { unsubscribe(); clear(); };
|
|
875
|
+
}));
|
|
876
|
+
}
|
|
877
|
+
function createSidebarMode(register) {
|
|
878
|
+
const listeners = new Set(); let native = false, registration = register(), disposed = false;
|
|
879
|
+
const store = { getSnapshot: () => native, subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
880
|
+
return { store, toggle() { if (disposed) return; if (native) registration = register(); else { registration(); registration = null; } native = !native; for (const listener of [...listeners]) listener(); }, dispose() { disposed = true; registration?.(); listeners.clear(); } };
|
|
881
|
+
}
|
|
882
|
+
const sidebarViewLabel = (sidebarMode, t) => t(sidebarMode.store.getSnapshot() ? 'switchProjectsView' : 'switchWorkspaceView');
|
|
883
|
+
function registerSidebarViewSwitch(ctx, sidebarMode, component) {
|
|
884
|
+
const t = ctx.locale.bind(NS);
|
|
885
|
+
return ctx.slots.inject('sidebar', () => ctx.slots.inject('sidebar.panellist', () => {
|
|
886
|
+
let removeRow = () => {};
|
|
887
|
+
const synchronize = () => {
|
|
888
|
+
removeRow();
|
|
889
|
+
removeRow = ctx.slots.register({ name: 'sidebar.panellist', id: VIEW_SWITCH, order: 400, label: () => sidebarViewLabel(sidebarMode, t) }, component);
|
|
890
|
+
};
|
|
891
|
+
synchronize();
|
|
892
|
+
const unsubscribe = sidebarMode.store.subscribe(synchronize);
|
|
893
|
+
// Keep native declarations installed. The renderer authorizes child
|
|
894
|
+
// dispatch per entry, so bridge the native component through owned aliases.
|
|
895
|
+
const native = ctx.slots.entriesOfSlot('sidebar')[0], inject = native.inject;
|
|
896
|
+
const mapKey = key => VIEW_SWITCH + '.' + key;
|
|
897
|
+
const children = Object.fromEntries(Object.entries(native.children).map(([key, spec]) => [mapKey(key), spec]));
|
|
898
|
+
const componentWithActions = props => h(native.component, { ...props, renderSlot: (key, owner, options) => props.renderSlot(mapKey(key), owner, options) });
|
|
899
|
+
const removeSidebar = ctx.slots.register({ name: 'sidebar', priority: -50, locale: native.locale, children, inject: (...args) => {
|
|
900
|
+
const props = inject(...args);
|
|
901
|
+
return { ...props, selectPanel(id) { if (id === VIEW_SWITCH) sidebarMode.toggle(); else props.selectPanel(id); } };
|
|
902
|
+
} }, componentWithActions);
|
|
903
|
+
const mirrors = Object.keys(native.children).map(key => mirrorProjectSlot(ctx, key, mapKey(key), mapKey));
|
|
904
|
+
return () => { for (const stop of mirrors) stop(); removeSidebar(); unsubscribe(); removeRow(); };
|
|
905
|
+
}));
|
|
906
|
+
}
|
|
907
|
+
function syncProjectSpinner(element) {
|
|
908
|
+
if (element === null) return;
|
|
909
|
+
for (const animation of element.getAnimations?.({ subtree: true }) ?? []) animation.startTime = 0;
|
|
910
|
+
}
|
|
911
|
+
function Icon({ size = 18 }) {
|
|
912
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7, 'aria-hidden': true },
|
|
913
|
+
h('path', { d: 'M6 5v14M6 9h6a6 6 0 0 0 6-6M6 15h6a6 6 0 0 1 6 6' }),
|
|
914
|
+
...[[6, 3], [6, 21], [18, 3], [18, 21]].map(([cx, cy]) => h('circle', { key: cx + '-' + cy, cx, cy, r: 2 })));
|
|
915
|
+
}
|
|
916
|
+
const Styles = () => h('style', null, css);
|
|
917
|
+
const Button = React.forwardRef(({ children, ...props }, ref) => h('button', { type: 'button', ...props, ref }, children));
|
|
918
|
+
function ProjectStateDot({ state }) {
|
|
919
|
+
if (state !== 'ongoing') return h('span', { className: 'dsh-wt-state-dot', 'data-state': state, 'aria-hidden': true });
|
|
920
|
+
return h('svg', { ref: syncProjectSpinner, className: 'dsh-wt-state-spinner', 'data-state': 'ongoing', width: 14, height: 14, viewBox: '0 0 24 24', 'aria-hidden': true },
|
|
921
|
+
h('g', { className: 'dsh-wt-state-motion' }, h('circle', { className: 'dsh-wt-state-track', cx: 12, cy: 12, r: 9.5 }), h('circle', { className: 'dsh-wt-state-arc', cx: 12, cy: 12, r: 9.5 })));
|
|
922
|
+
}
|
|
923
|
+
// Native workspace-folder artwork copied locally; React is the only runtime import.
|
|
924
|
+
function WorkspaceFolderIcon({ expanded = false, className, size = 16 }) {
|
|
925
|
+
return h('svg', { width: size, height: size, className, viewBox: '0 0 16 16', fill: 'none', strokeWidth: 1, 'aria-hidden': true }, expanded ? [
|
|
926
|
+
h('path', { key: 'tint', d: 'M2.55912 7.93683C2.67584 7.49906 3.0723 7.19446 3.52536 7.19446H13.6491C14.3061 7.19446 14.7846 7.81725 14.6153 8.45209L13.4411 12.856C13.3244 13.2938 12.9279 13.5984 12.4748 13.5984H2.35113C1.69411 13.5984 1.21562 12.9756 1.38489 12.3407L2.55912 7.93683Z', fill: 'currentColor', opacity: 0.16 }),
|
|
927
|
+
h('path', { key: 'front', d: 'M13.6491 6.69446C14.6346 6.69453 15.3522 7.62895 15.0983 8.58118L13.9245 12.9845C13.7494 13.6412 13.1539 14.0988 12.4743 14.0988H2.35126C1.36574 14.0988 0.648153 13.1643 0.902044 12.212L2.07587 7.80774C2.25102 7.15128 2.84567 6.69455 3.52509 6.69446H13.6491ZM3.52509 7.69446C3.29865 7.69455 3.10004 7.84674 3.04169 8.06555L1.86786 12.4698C1.78345 12.7872 2.02285 13.0988 2.35126 13.0988H12.4743C12.7007 13.0988 12.8992 12.9463 12.9577 12.7277L14.1325 8.32336C14.2171 8.00598 13.9776 7.69453 13.6491 7.69446H3.52509Z', fill: 'currentColor' }),
|
|
928
|
+
h('path', { key: 'back', d: 'M4.7666 1.90137C5.13227 1.90144 5.48571 2.03525 5.75977 2.27734L7.27246 3.61328C7.36379 3.69382 7.48174 3.73828 7.60352 3.73828H12.3994C13.2276 3.73841 13.8993 4.41005 13.8994 5.23828V6.7168C13.8183 6.70327 13.735 6.69436 13.6494 6.69434H12.8994V5.23828C12.8993 4.96233 12.6754 4.73841 12.3994 4.73828H7.60352C7.23781 4.73828 6.88446 4.60438 6.61035 4.3623L5.09766 3.02637C5.00636 2.94576 4.88838 2.90144 4.7666 2.90137H2.0498C1.77366 2.90137 1.5498 3.12523 1.5498 3.40137V9.78223L0.902344 12.2119C0.648452 13.1642 1.36604 14.0986 2.35156 14.0986H2.0498C1.2214 14.0986 0.549838 13.427 0.549805 12.5986V3.40137C0.549805 2.57294 1.22138 1.90137 2.0498 1.90137H4.7666Z', fill: 'currentColor' }),
|
|
929
|
+
] : [
|
|
930
|
+
h('path', { key: 'outline', d: 'M1.50439 3.11059C1.50439 2.55831 1.95211 2.1106 2.50439 2.1106H5.43389C5.67773 2.1106 5.91318 2.19969 6.09593 2.36113L7.71649 3.79265C7.89924 3.95409 8.1347 4.04319 8.3785 4.04319H13.4958C14.0481 4.04319 14.4958 4.4909 14.4958 5.04319V12.8894C14.4958 13.4417 14.0481 13.8894 13.4958 13.8894H2.50439C1.95211 13.8894 1.50439 13.4417 1.50439 12.8894V4.04319V3.11059Z', stroke: 'currentColor' }),
|
|
931
|
+
h('path', { key: 'line', d: 'M3.63501 7.66614H12.3647', stroke: 'currentColor' }),
|
|
932
|
+
]);
|
|
933
|
+
}
|
|
934
|
+
function SidebarToolbarIcon({ kind, size = 16 }) {
|
|
935
|
+
const svg = { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', strokeWidth: 1, 'aria-hidden': true };
|
|
936
|
+
if (kind === 'search') return h('svg', svg, h('path', { d: 'M6.58727 11.8586C9.55061 11.8586 11.9529 9.45637 11.9529 6.49304C11.9529 3.5297 9.55061 1.12744 6.58727 1.12744C3.62394 1.12744 1.22168 3.5297 1.22168 6.49304C1.22168 9.45637 3.62394 11.8586 6.58727 11.8586Z', stroke: 'currentColor' }), h('path', { d: 'M10.2991 10.3933L14.7783 14.8725', stroke: 'currentColor' }));
|
|
937
|
+
if (kind === 'add') return h('svg', svg, h('path', { d: 'M5.54492 2.06738C5.91034 2.06754 6.26318 2.20149 6.53711 2.44336L7.94043 3.68164V4.7998C7.71462 4.74105 7.50367 4.63139 7.32617 4.47461L5.87598 3.19238C5.78477 3.11185 5.66658 3.06754 5.54492 3.06738H2.94922C2.67322 3.06738 2.44946 3.29145 2.44922 3.56738V12.4326C2.44927 12.7087 2.67311 12.9326 2.94922 12.9326H12.9326C13.2086 12.9325 13.4326 12.7086 13.4326 12.4326V8.53613H14.4326V12.4326C14.4326 13.2609 13.7609 13.9325 12.9326 13.9326H2.94922C2.12083 13.9326 1.44927 13.261 1.44922 12.4326V3.56738C1.44946 2.73916 2.12094 2.06738 2.94922 2.06738H5.54492Z', fill: 'currentColor' }), h('path', { d: 'M9.75977 4.50208H14.5509', stroke: 'currentColor' }), h('path', { d: 'M12.1492 6.89758L12.1492 2.10642', stroke: 'currentColor' }));
|
|
938
|
+
return h('svg', { ...svg, stroke: 'currentColor', strokeLinecap: 'round' }, h('path', { d: 'M2.3 5h5.85M12.05 5h1.65' }), h('circle', { cx: 9.95, cy: 5, r: 1.45 }), h('path', { d: 'M2.3 11h1.65M7.85 11h5.85' }), h('circle', { cx: 5.75, cy: 11, r: 1.45 }));
|
|
939
|
+
}
|
|
940
|
+
function FolderIcon({ plus = false, size = 16 }) {
|
|
941
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
942
|
+
h('path', { d: 'M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7h18' }), plus && h('path', { d: 'M12 11v6m-3-3h6' }));
|
|
943
|
+
}
|
|
944
|
+
function Dialog({ title, close, t, children, className = '', compact = false, dismissible = true, initialFocus, returnFocus, fallbackFocus, descriptionId }) {
|
|
945
|
+
const ref = React.useRef(null), heading = React.useId();
|
|
946
|
+
React.useEffect(() => {
|
|
947
|
+
const dialog = ref.current; dialog.showModal(); initialFocus?.current?.focus();
|
|
948
|
+
return () => { if (dialog.open) dialog.close(); if (returnFocus?.current?.isConnected) returnFocus.current.focus(); else fallbackFocus?.current?.focus(); };
|
|
949
|
+
}, []);
|
|
950
|
+
return h('dialog', { ref, className: 'dsh-wt dsh-wt-dialog ' + className, 'aria-labelledby': heading, 'aria-describedby': descriptionId, onCancel: event => { event.preventDefault(); event.stopPropagation(); if (dismissible) close(); } },
|
|
951
|
+
h(Styles), h('div', { className: 'dsh-wt-row dsh-wt-between dsh-wt-dialog-heading' }, h('h2', { id: heading }, title), h(Button, { className: compact ? 'dsh-wt-dialog-close' : undefined, onClick: close, disabled: !dismissible, autoFocus: !compact, 'aria-label': t('close') }, compact ? '×' : t('close'))), children);
|
|
952
|
+
}
|
|
953
|
+
function FolderRows({ folders, remove, disabled, t }) {
|
|
954
|
+
return h('ul', { className: 'dsh-wt-source-list', 'aria-label': t('selectedFolders') }, ...folders.map(folder => h('li', { key: folder }, h(FolderIcon),
|
|
955
|
+
h('span', { className: 'dsh-wt-source-info' }, h('span', { title: folder }, projectFolderName(folder)), h('span', { className: 'dsh-wt-source-path', title: folder }, folder)),
|
|
956
|
+
h(Button, { className: 'dsh-wt-source-remove', disabled, onClick: () => remove(folder), 'aria-label': t('remove') + ' ' + folder }, '×'))));
|
|
957
|
+
}
|
|
958
|
+
function FolderBrowser({ runtime: ctx, t, existing = [], picked, close }) {
|
|
959
|
+
const [state, setState] = React.useState({ listing: null, busy: false, error: null });
|
|
960
|
+
const [path, setPath] = React.useState(''), [name, setName] = React.useState(''), [selected, setSelected] = React.useState([]), [error, setError] = React.useState(''), [creating, setCreating] = React.useState(false);
|
|
961
|
+
const scanner = React.useRef(null), alive = React.useRef(true), creation = React.useRef(false), selection = React.useRef([]), pathInput = React.useRef(null);
|
|
962
|
+
React.useEffect(() => {
|
|
963
|
+
alive.current = true;
|
|
964
|
+
const owned = createFolderScanner(ctx.uiWorkspace, patch => { setState(previous => ({ ...previous, ...patch })); if (patch.listing) setPath(patch.listing.path); }); scanner.current = owned; void owned.scan();
|
|
965
|
+
return () => { alive.current = false; owned.dispose(); if (scanner.current === owned) scanner.current = null; };
|
|
966
|
+
}, [ctx]);
|
|
967
|
+
const { listing, busy } = state, added = new Set(existing), capacity = 32 - existing.length;
|
|
968
|
+
const dismiss = () => { if (!creation.current) close(); };
|
|
969
|
+
function toggle(path) {
|
|
970
|
+
try { const next = toggleProjectFolder(selection.current, path, capacity); selection.current = next; setSelected(next); setError(''); }
|
|
971
|
+
catch (failure) { setError(report(t, failure)); }
|
|
972
|
+
}
|
|
973
|
+
function checkbox(path) {
|
|
974
|
+
const present = added.has(path), checked = present || selected.includes(path);
|
|
975
|
+
return h('label', { className: 'dsh-wt-picker-check', title: present ? t('alreadyAdded') : t('selectFolder') + ' ' + path }, h('input', { type: 'checkbox', checked, disabled: busy || creating || present || (!checked && selected.length >= capacity), 'aria-label': t('selectFolder') + ' ' + path, onChange: () => toggle(path) }));
|
|
976
|
+
}
|
|
977
|
+
async function create(event) {
|
|
978
|
+
event.preventDefault(); if (creation.current || busy || !listing || !name.trim()) return;
|
|
979
|
+
creation.current = true; setCreating(true); setError(''); const owned = scanner.current;
|
|
980
|
+
try { const created = await ctx.uiWorkspace.createDirectory(listing.path, name.trim()); if (alive.current && scanner.current === owned) { setName(''); await owned.scan(created); } }
|
|
981
|
+
catch (failure) { if (alive.current && scanner.current === owned) setError(report(t, failure)); }
|
|
982
|
+
finally { creation.current = false; if (alive.current) setCreating(false); }
|
|
983
|
+
}
|
|
984
|
+
return h(Dialog, { title: t('sourceFolders'), close: dismiss, t, className: 'dsh-wt-project-dialog dsh-wt-folder-picker', compact: true, dismissible: !creating, initialFocus: pathInput },
|
|
985
|
+
h('p', { className: 'dsh-wt-muted' }, t('multiFolderHelp')), h('p', { className: 'dsh-wt-muted' }, t('hostFolderHelp')),
|
|
986
|
+
h('form', { className: 'dsh-wt-picker-path', onSubmit: event => { event.preventDefault(); if (!creating) { setError(''); void scanner.current?.scan(path); } } }, h('label', { className: 'dsh-wt-field' }, t('absolutePath'), h('input', { value: path, ref: pathInput, disabled: creating, onChange: event => setPath(event.target.value) })), h('button', { type: 'submit', disabled: busy || creating || !absolutePath(path) }, t('browse'))),
|
|
987
|
+
listing && h(React.Fragment, null, h('nav', { className: 'dsh-wt-row dsh-wt-picker-crumbs', 'aria-label': t('browse') }, ...listing.crumbs.map(crumb => h(Button, { key: crumb.path, disabled: busy || creating, title: crumb.path, onClick: () => { void scanner.current?.scan(crumb.path); } }, crumb.name))),
|
|
988
|
+
h('div', { className: 'dsh-wt-picker-current' }, checkbox(listing.path), h(FolderIcon), h('span', { className: 'dsh-wt-source-info' }, h('span', null, t('chooseThisFolder')), h('span', { className: 'dsh-wt-source-path', title: listing.path }, listing.path))),
|
|
989
|
+
h('ul', { className: 'dsh-wt-picker-entries', 'aria-label': t('folders') }, ...listing.entries.map(entry => h('li', { key: entry.path }, checkbox(entry.path), h(FolderIcon), h(Button, { className: 'dsh-wt-picker-open', disabled: busy || creating, title: entry.path, onClick: () => { void scanner.current?.scan(entry.path); } }, entry.name), added.has(entry.path) && h('span', { className: 'dsh-wt-muted' }, t('alreadyAdded'))))),
|
|
990
|
+
!listing.entries.length && !busy && h('p', { className: 'dsh-wt-muted' }, t('noSubfolders')), listing.truncated && h('p', { role: 'status', className: 'dsh-wt-muted' }, t('directoryTruncated')),
|
|
991
|
+
h('details', { className: 'dsh-wt-picker-create' }, h('summary', null, t('createFolder')), h('form', { onSubmit: create }, h('label', { className: 'dsh-wt-field' }, t('folderName'), h('input', { value: name, disabled: busy || creating, onChange: event => setName(event.target.value) })), h('button', { type: 'submit', disabled: busy || creating || !name.trim() }, t('createFolder'))))),
|
|
992
|
+
selected.length > 0 && h('section', { className: 'dsh-wt-picker-selected' }, h('h3', { 'aria-live': 'polite' }, t('selectedFolders') + ' · ' + selected.length), h(FolderRows, { folders: selected, remove: toggle, disabled: creating, t })),
|
|
993
|
+
(busy || creating) && h('p', { role: 'status' }, t('loading')), (error || state.error) && h('p', { role: 'alert', className: 'dsh-wt-error' }, error || report(t, state.error)),
|
|
994
|
+
h('div', { className: 'dsh-wt-project-footer' }, h(Button, { className: 'dsh-wt-cancel', disabled: creating, onClick: dismiss }, t('cancel')), h(Button, { className: 'dsh-wt-confirm', disabled: busy || creating || !selected.length, onClick: () => picked([...selection.current]) }, t('add') + (selected.length ? ' · ' + selected.length : ''))));
|
|
995
|
+
}
|
|
996
|
+
function ConfirmProjectRemoval({ project, busy, error, t, close, confirm, returnFocus }) {
|
|
997
|
+
const cancel = React.useRef(null), description = React.useId();
|
|
998
|
+
return h(Dialog, { title: t('removeProject'), close, t, className: 'dsh-wt-project-dialog', compact: true, dismissible: !busy, initialFocus: cancel, returnFocus, descriptionId: description },
|
|
999
|
+
h('p', null, t('removeProjectConfirm'), ' ', h('strong', null, project.title)),
|
|
1000
|
+
h('p', { id: description, className: 'dsh-wt-muted' }, t('removeProjectHelp')),
|
|
1001
|
+
error && h('p', { role: 'alert', className: 'dsh-wt-error' }, error),
|
|
1002
|
+
h('div', { className: 'dsh-wt-project-footer', 'aria-busy': busy }, h(Button, { ref: cancel, className: 'dsh-wt-cancel', disabled: busy, onClick: close }, t('cancel')), h(Button, { className: 'dsh-wt-danger', disabled: busy, onClick: confirm }, t(busy ? 'loading' : 'removeProject'))));
|
|
1003
|
+
}
|
|
1004
|
+
function ProjectEditor({ project, runtime: ctx, projects, t, close, returnFocus, fallbackFocus }) {
|
|
1005
|
+
const [title, setTitle] = React.useState(project?.title || ''), [folders, setFolders] = React.useState(project?.folders.map(folder => folder.path) || []), [path, setPath] = React.useState(''), [mainFolder, setMainFolder] = React.useState(projectMainFolder(project)?.path || '');
|
|
1006
|
+
const [busy, setBusy] = React.useState(false), [error, setError] = React.useState(''), [browser, setBrowser] = React.useState(false), [addMode, setAddMode] = React.useState('browse'), [confirmRemoval, setConfirmRemoval] = React.useState(false);
|
|
1007
|
+
const lifetime = React.useRef(true), currentFolders = React.useRef(folders), picker = React.useRef(false), browsing = React.useRef(false), confirming = React.useRef(false), submitter = React.useRef(null), heading = React.useId(), nameInput = React.useRef(null), removeTrigger = React.useRef(null);
|
|
1008
|
+
if (!submitter.current) submitter.current = createProjectSubmitter(projects, project?.id, project?.id || crypto.randomUUID());
|
|
1009
|
+
React.useEffect(() => { lifetime.current = true; return () => { lifetime.current = false; }; }, []);
|
|
1010
|
+
const dismiss = () => { if (!submitter.current.pending && !picker.current && !confirming.current) close(); };
|
|
1011
|
+
const primary = draftMainFolder(folders, mainFolder);
|
|
1012
|
+
function closeBrowser() { browsing.current = false; setBrowser(false); }
|
|
1013
|
+
function add(paths) {
|
|
1014
|
+
if (!lifetime.current || submitter.current.pending || confirming.current) return;
|
|
1015
|
+
try { const next = appendProjectFolders(currentFolders.current, paths); currentFolders.current = next; setFolders(next); setPath(''); closeBrowser(); setError(''); }
|
|
1016
|
+
catch (failure) { setError(report(t, failure)); }
|
|
1017
|
+
}
|
|
1018
|
+
function remove(path) { if (submitter.current.pending || picker.current || browsing.current || confirming.current) return; currentFolders.current = currentFolders.current.filter(value => value !== path); setFolders(currentFolders.current); if (mainFolder === path) setMainFolder(''); setError(''); }
|
|
1019
|
+
async function choose() {
|
|
1020
|
+
if (submitter.current.pending || picker.current || browsing.current || confirming.current || currentFolders.current.length >= 32) return;
|
|
1021
|
+
setError('');
|
|
1022
|
+
if (addMode === 'path') { add([path.trim()]); return; }
|
|
1023
|
+
if (addMode === 'browse') { browsing.current = true; setBrowser(true); return; }
|
|
1024
|
+
// The optional native chooser returns one folder or null, never a batch.
|
|
1025
|
+
picker.current = true; setBusy(true);
|
|
1026
|
+
try { const selected = await ctx.uiWorkspace.pickDirectory(); if (lifetime.current && selected) add([selected]); }
|
|
1027
|
+
catch (failure) { if (lifetime.current) setError(report(t, failure)); }
|
|
1028
|
+
finally { picker.current = false; if (lifetime.current) setBusy(false); }
|
|
1029
|
+
}
|
|
1030
|
+
async function save(event) {
|
|
1031
|
+
event.preventDefault(); if (submitter.current.pending || picker.current || browsing.current || confirming.current || !title.trim() || !currentFolders.current.length) return;
|
|
1032
|
+
if (!draftMainFolder(currentFolders.current, mainFolder)) { setError(t('mainFolderRequired')); return; }
|
|
1033
|
+
setBusy(true); setError('');
|
|
1034
|
+
try { await submitter.current.submit(title, currentFolders.current, mainFolder); if (lifetime.current) close(); }
|
|
1035
|
+
catch (failure) { if (lifetime.current) setError(report(t, failure)); }
|
|
1036
|
+
finally { if (lifetime.current) setBusy(false); }
|
|
1037
|
+
}
|
|
1038
|
+
function beginRemoval() {
|
|
1039
|
+
if (!project || submitter.current.pending || picker.current || browsing.current || confirming.current) return;
|
|
1040
|
+
confirming.current = true; setError(''); setConfirmRemoval(true);
|
|
1041
|
+
}
|
|
1042
|
+
function dismissRemoval() { if (!submitter.current.pending) { confirming.current = false; setConfirmRemoval(false); setError(''); } }
|
|
1043
|
+
async function removeProject() {
|
|
1044
|
+
if (!confirming.current || submitter.current.pending || picker.current || browsing.current) return;
|
|
1045
|
+
setBusy(true); setError('');
|
|
1046
|
+
try { await submitter.current.remove(); if (lifetime.current) close(); }
|
|
1047
|
+
catch (failure) { if (lifetime.current) setError(report(t, failure)); }
|
|
1048
|
+
finally { if (lifetime.current) setBusy(false); }
|
|
1049
|
+
}
|
|
1050
|
+
return h(Dialog, { title: t(project ? 'editProject' : 'createProject'), close: dismiss, t, className: 'dsh-wt-project-dialog', compact: true, dismissible: !busy, initialFocus: nameInput, returnFocus, fallbackFocus },
|
|
1051
|
+
h('form', { onSubmit: save, 'aria-busy': busy }, h('label', { className: 'dsh-wt-project-name' }, h(FolderIcon), h('input', { required: true, ref: nameInput, maxLength: 120, placeholder: t('projectName'), 'aria-label': t('projectName'), value: title, disabled: busy, onChange: event => setTitle(event.target.value) })),
|
|
1052
|
+
h('div', { className: 'dsh-wt-source-heading' }, h('span', { id: heading }, t('sourceFolders')), folders.length > 0 && h('span', { className: 'dsh-wt-source-count', 'aria-live': 'polite' }, folders.length + ' / 32')),
|
|
1053
|
+
h('section', { className: 'dsh-wt-source-card', 'aria-labelledby': heading, 'data-filled': !!folders.length }, folders.length > 0 && h(FolderRows, { folders, remove, disabled: busy, t }),
|
|
1054
|
+
h('div', { className: 'dsh-wt-source-add' }, h('select', { className: 'dsh-wt-source-select', 'aria-label': t('addFolder'), value: addMode, disabled: busy || folders.length >= 32, onChange: event => { setAddMode(event.target.value); setError(''); } }, h('option', { value: 'browse' }, t('localFolders')), h('option', { value: 'native' }, t('chooseDirectory')), h('option', { value: 'path' }, t('enterFolderPath'))),
|
|
1055
|
+
addMode === 'path' && h('label', { className: 'dsh-wt-project-path' }, h('input', { value: path, autoFocus: true, placeholder: t('absolutePath'), 'aria-label': t('absolutePath'), disabled: busy || folders.length >= 32, onChange: event => setPath(event.target.value), onKeyDown: event => { if (event.key === 'Enter' && !event.nativeEvent?.isComposing) { event.preventDefault(); void choose(); } } })),
|
|
1056
|
+
h(Button, { className: 'dsh-wt-add-pill', disabled: busy || folders.length >= 32 || (addMode === 'path' && !absolutePath(path.trim())), onClick: () => { void choose(); } }, h(FolderIcon, { plus: true }), t('add')))),
|
|
1057
|
+
folders.length > 1 && h('label', { className: 'dsh-wt-main-folder' }, t('mainFolder'), h('select', { required: true, value: primary, disabled: busy, title: primary, onChange: event => { setMainFolder(event.target.value); setError(''); } }, h('option', { value: '', disabled: true }, t('chooseMainFolder')), ...folders.map(folder => h('option', { key: folder, value: folder }, projectFolderName(folder) + ' · ' + folder))), h('small', null, t('mainFolderHelp'))),
|
|
1058
|
+
error && !confirmRemoval && h('p', { role: 'alert', className: 'dsh-wt-error' }, error), h('div', { className: 'dsh-wt-project-footer' }, project && h(Button, { ref: removeTrigger, className: 'dsh-wt-remove-project', disabled: busy || browser || confirmRemoval, onClick: beginRemoval }, t('removeProject')), h(Button, { className: 'dsh-wt-cancel', disabled: busy, onClick: dismiss }, t('cancel')), h('button', { className: 'dsh-wt-confirm', type: 'submit', disabled: busy || !title.trim() || !folders.length || !primary }, t(busy ? 'loading' : project ? 'save' : 'createProject')))),
|
|
1059
|
+
browser && h(FolderBrowser, { runtime: ctx, t, existing: folders, picked: add, close: closeBrowser }),
|
|
1060
|
+
confirmRemoval && project && h(ConfirmProjectRemoval, { project, busy, error, t, close: dismissRemoval, confirm: removeProject, returnFocus: removeTrigger }));
|
|
1061
|
+
}
|
|
1062
|
+
function ThreadRow({ row, selected, runtime: ctx, metadata, renderSlot, t, now }) {
|
|
1063
|
+
const [menu, setMenu] = React.useState(false), [hover, setHover] = React.useState(false);
|
|
1064
|
+
const activity = row.session.blank ? null : projectSessionTime(row.session.updatedAt, now, t);
|
|
1065
|
+
const worktree = (row.backing?.mode || row.context?.binding.mode) === 'worktree';
|
|
1066
|
+
const record = worktree ? row.backing?.record || metadata.records.find(item => item.id === row.context?.binding.worktreeId) : undefined;
|
|
1067
|
+
const path = row.backing?.effectiveCwd || row.context?.binding.effectiveCwd || row.context?.folder.path || '';
|
|
1068
|
+
const title = row.session.blank ? t('blankThread') : row.title || t('untitled');
|
|
1069
|
+
const marker = (worktree ? t('worktreeThread') : t('localThread')) + (row.context ? ' · ' + row.context.folder.path : '') + (record?.branch ? ' · ' + record.branch : '') + (worktree ? ' · ' + path : '');
|
|
1070
|
+
const state = projectRowState(row), status = row.pending ? t('waiting') : row.running ? t('busy') : row.done ? t('done') : t('idle');
|
|
1071
|
+
const owner = { sessionId: row.id, displayTitle: row.title };
|
|
1072
|
+
return h('div', { className: 'dsh-wt-thread' + (row.archived ? ' dsh-wt-thread-archived' : ''), role: 'treeitem', 'aria-selected': selected, 'data-menu-open': menu, 'aria-description': row.archived ? t('archived') : undefined },
|
|
1073
|
+
h('div', { className: 'dsh-wt-thread-main' }, h(Button, { disabled: row.archived, title: title + ' · ' + status + (worktree ? ' · ' + marker : ''), onClick: () => ctx.uiWorkspace.openSession(row.id) },
|
|
1074
|
+
h('span', { className: 'dsh-wt-thread-leading', role: state !== 'idle' ? 'img' : undefined, 'aria-label': state !== 'idle' ? status : undefined }, !row.archived && !row.session.blank && (state !== 'idle' ? h(ProjectStateDot, { state }) : renderSlot(projectSlotName('sidebar.session.row.leading'), { sessionId: row.id }))),
|
|
1075
|
+
h('span', { className: 'dsh-wt-thread-title' }, title)),
|
|
1076
|
+
row.pinned && h('span', { className: 'dsh-wt-muted dsh-wt-thread-pin', title: t('pinned'), 'aria-label': t('pinned') }, '⌖'),
|
|
1077
|
+
worktree && h('span', { className: 'dsh-wt-branch-chip', title: marker, 'aria-label': marker, role: 'img' }, h(Icon, { size: 13 })),
|
|
1078
|
+
activity && h('time', { className: 'dsh-wt-thread-time', dateTime: activity.dateTime, title: activity.title, 'aria-label': activity.title }, activity.label),
|
|
1079
|
+
!row.session.blank && h('span', { className: 'dsh-wt-thread-actions' }, h(Button, { 'aria-label': t('threadActions') + ' ' + title, onClick: () => setMenu(true) }, '…'), renderSlot(projectSlotName('sidebar.workspaces.session.row.action'), owner), h(Button, { 'aria-label': t('threadDetails'), onClick: () => setHover(value => !value), 'aria-expanded': hover }, 'ⓘ'))),
|
|
1080
|
+
hover && h('div', { className: 'dsh-wt-thread-hover' }, h('div', null, title), h('div', { className: 'dsh-wt-muted' }, status), worktree && record?.branch && h('div', null, h('code', null, record.branch)), h('code', null, path), renderSlot(projectSlotName('sidebar.session.row.hover'), { sessionId: row.id })),
|
|
1081
|
+
menu && h(Dialog, { title: t('threadActions'), t, close: () => setMenu(false) }, h('div', { role: 'menu', className: 'dsh-wt-project-menu', onKeyDown: event => { if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; const items = [...event.currentTarget.querySelectorAll('[role=menuitem]:not(:disabled)')]; if (!items.length) return; event.preventDefault(); const at = items.indexOf(event.target); items[event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus(); } }, renderSlot(projectSlotName('sidebar.workspaces.session.menu.item'), owner, { hookContext: [menu, setMenu] }))));
|
|
1082
|
+
}
|
|
1083
|
+
function ProjectGroup({ group, selectedId, runtime, metadata, renderSlot, t, searching, onManage, now }) {
|
|
1084
|
+
const [collapsed, setCollapsed] = React.useState(false), [limit, setLimit] = React.useState(5), [choosingFolder, setChoosingFolder] = React.useState(false);
|
|
1085
|
+
const rows = searching ? group.rows : visibleProjectRows(group.rows, limit, selectedId), main = projectMainFolder(group.project), remaining = group.rows.length - rows.length;
|
|
1086
|
+
const hasOverflow = group.rows.length > visibleProjectRows(group.rows, 5, selectedId).length;
|
|
1087
|
+
function start(folderId) { setChoosingFolder(false); runtime.uiWorkspace.startSession(folderId); }
|
|
1088
|
+
return h('section', { className: 'dsh-wt-project-group' }, h('div', { className: 'dsh-wt-project-head' }, h(Button, { title: group.project?.folders.map(folder => folder.path).join('\n'), 'aria-expanded': searching || !collapsed, onClick: () => { setCollapsed(value => !value); setLimit(5); }, 'aria-label': t(collapsed ? 'expand' : 'collapse') + ' ' + (group.project?.title || t('unassigned')) },
|
|
1089
|
+
h('span', { className: 'dsh-wt-project-glyph', 'aria-hidden': true }, h(WorkspaceFolderIcon, { expanded: searching || !collapsed, className: 'dsh-wt-project-folder' }), h('span', { className: 'dsh-wt-project-chevron' }, collapsed && !searching ? '▸' : '▾')), h('span', { className: 'dsh-wt-project-title' }, group.project?.title || t('unassigned'))),
|
|
1090
|
+
group.project && h('span', { className: 'dsh-wt-project-head-actions' }, h(Button, { 'aria-label': t('newThread') + ' ' + group.project.title, title: t('newThread') + (main ? ' · ' + main.path : ''), disabled: !main, onClick: () => main && start(main.id) }, '+'), group.project.folders.length > 1 && h(Button, { 'aria-label': t('chooseFolder') + ' ' + group.project.title, title: t('chooseFolder'), 'aria-expanded': choosingFolder, onClick: () => setChoosingFolder(value => !value) }, '▾'), h(Button, { 'aria-label': t('editProject') + ' ' + group.project.title, title: t('editProject'), onClick: event => onManage(group.project, event.currentTarget) }, '…'))),
|
|
1091
|
+
choosingFolder && group.project && h('div', { className: 'dsh-wt-row', 'aria-label': t('chooseFolder') }, ...group.project.folders.map(folder => h(Button, { key: folder.id, title: folder.path, onClick: () => start(folder.id) }, folder.title + (folder.id === main?.id ? ' · ' + t('main') : '')))),
|
|
1092
|
+
(searching || !collapsed) && h('div', { role: 'group' }, ...rows.map(row => h(ThreadRow, { key: row.id, row, selected: row.id === selectedId, runtime, metadata, renderSlot, t, now })),
|
|
1093
|
+
!searching && hasOverflow && h(Button, { className: 'dsh-wt-session-overflow', 'aria-expanded': remaining === 0, onClick: () => setLimit(value => remaining === 0 ? 5 : remaining <= 5 ? Infinity : value + 5) }, remaining ? t('showMore', { n: remaining }) : t('showLess'))));
|
|
1094
|
+
}
|
|
1095
|
+
function SidebarToggle({ size }) {
|
|
1096
|
+
return h(WorkspaceFolderIcon, { size });
|
|
1097
|
+
}
|
|
1098
|
+
function ProjectSidebarToolbar({ query, onQuery, archived, onArchived, onCreate, newProjectTrigger, t }) {
|
|
1099
|
+
const [searchOpen, setSearchOpen] = React.useState(!!query), [optionsOpen, setOptionsOpen] = React.useState(false);
|
|
1100
|
+
const input = React.useRef(null), searchTrigger = React.useRef(null), optionsTrigger = React.useRef(null), searchId = React.useId(), menuId = React.useId();
|
|
1101
|
+
React.useEffect(() => { if (searchOpen) input.current?.focus(); }, [searchOpen]);
|
|
1102
|
+
function closeSearch() { onQuery(''); setSearchOpen(false); searchTrigger.current?.focus(); }
|
|
1103
|
+
function closeOptions() { setOptionsOpen(false); optionsTrigger.current?.focus(); }
|
|
1104
|
+
function menuKey(event) {
|
|
1105
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeOptions(); return; }
|
|
1106
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
|
1107
|
+
const items = [...event.currentTarget.querySelectorAll('[role=menuitemradio]')], index = items.indexOf(event.target); if (!items.length) return;
|
|
1108
|
+
event.preventDefault(); items[event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
|
|
1109
|
+
}
|
|
1110
|
+
return h('div', { className: 'dsh-wt-projects-header dsh-wt-project-toolbar' },
|
|
1111
|
+
!searchOpen && h('span', { className: 'dsh-wt-project-section-label' }, t('projects')),
|
|
1112
|
+
searchOpen && h('div', { className: 'dsh-wt-project-search' }, h('input', { id: searchId, ref: input, type: 'search', maxLength: 256, className: 'dsh-wt-project-search-input', value: query, placeholder: t('filterThreads'), 'aria-label': t('filterThreads'), onChange: event => onQuery(event.target.value), onKeyDown: event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeSearch(); } } }), h(Button, { className: 'dsh-wt-toolbar-icon', 'aria-label': t('closeSearch'), title: t('closeSearch'), onClick: closeSearch }, '×')),
|
|
1113
|
+
h(Button, { ref: searchTrigger, className: 'dsh-wt-toolbar-icon', 'aria-label': t('searchProjects'), title: t('searchProjects'), 'aria-expanded': searchOpen, 'aria-controls': searchOpen ? searchId : undefined, onClick: () => { setOptionsOpen(false); if (searchOpen) closeSearch(); else setSearchOpen(true); } }, h(SidebarToolbarIcon, { kind: 'search', size: 14 })),
|
|
1114
|
+
h('div', { className: 'dsh-wt-view-options', onBlur: event => { if (!event.currentTarget.contains(event.relatedTarget)) setOptionsOpen(false); } },
|
|
1115
|
+
h(Button, { ref: optionsTrigger, className: 'dsh-wt-toolbar-icon', 'aria-label': t('viewOptions'), title: t('viewOptions'), 'aria-haspopup': 'menu', 'aria-expanded': optionsOpen, 'aria-controls': optionsOpen ? menuId : undefined, 'data-active': archived !== 'hide', onClick: () => setOptionsOpen(value => !value), onKeyDown: event => { if (event.key === 'ArrowDown') { event.preventDefault(); setOptionsOpen(true); } else if (event.key === 'Escape') { event.preventDefault(); closeOptions(); } } }, h(SidebarToolbarIcon, { kind: 'options' })),
|
|
1116
|
+
optionsOpen && h('div', { id: menuId, role: 'menu', 'aria-label': t('viewOptions'), className: 'dsh-wt-view-menu', onKeyDown: menuKey }, ...[['hide', 'hideArchived'], ['all', 'allThreads'], ['only', 'archivedOnly']].map(([value, label]) => h(Button, { key: value, role: 'menuitemradio', 'aria-checked': archived === value, autoFocus: archived === value, onClick: () => { onArchived(value); closeOptions(); } }, h('span', { 'aria-hidden': true, className: 'dsh-wt-view-check' }, archived === value ? '✓' : ''), t(label))))),
|
|
1117
|
+
h(Button, { ref: newProjectTrigger, className: 'dsh-wt-toolbar-icon', 'aria-label': t('newProject'), title: t('newProject'), onClick: event => { setOptionsOpen(false); onCreate(event); } }, h(SidebarToolbarIcon, { kind: 'add' })));
|
|
1118
|
+
}
|
|
1119
|
+
function ProjectsSidebar({ runtime: ctx, projects, t, wide, expandSidebar, useWorkspaces, useSessions, usePanelInfo, useSessionStatus, renderSlot }) {
|
|
1120
|
+
const workspaces = useWorkspaces(value => value), sessions = useSessions(value => value), panel = usePanelInfo(value => value.activePanelId), statuses = useSessionStatus(value => value);
|
|
1121
|
+
const metadata = React.useSyncExternalStore(projects.store.subscribe, projects.store.getSnapshot);
|
|
1122
|
+
const [query, setQuery] = React.useState(''), [archived, setArchived] = React.useState('hide'), [editor, setEditor] = React.useState(null);
|
|
1123
|
+
const editorTrigger = React.useRef(null), newProjectTrigger = React.useRef(null);
|
|
1124
|
+
const selectedId = panel ? undefined : Object.keys(sessions.byId).find(id => (sessions.byId[id].retainedBy?.mainView || 0) > 0);
|
|
1125
|
+
const groups = projectRows(metadata, workspaces, sessions, statuses, { query, archived, selectedId }), now = Date.now();
|
|
1126
|
+
// Own the editor snapshot here: refresh/removal of a row must not discard refused edits.
|
|
1127
|
+
function openEditor(project, trigger) { editorTrigger.current = trigger; setEditor({ project }); }
|
|
1128
|
+
if (!wide) return h('div', { className: 'dsh-wt dsh-wt-projects' }, h(Styles), h(Button, { title: t('projects'), 'aria-label': t('projects'), onClick: expandSidebar }, h(WorkspaceFolderIcon)));
|
|
1129
|
+
return h('section', { className: 'dsh-wt dsh-wt-projects', 'aria-label': t('projects') }, h(Styles),
|
|
1130
|
+
h(ProjectSidebarToolbar, { query, onQuery: setQuery, archived, onArchived: setArchived, newProjectTrigger, onCreate: event => openEditor(null, event.currentTarget), t }),
|
|
1131
|
+
metadata.loading && h('span', { role: 'status', className: 'dsh-wt-muted' }, t('loading')), metadata.error && h('div', null, h('p', { role: 'alert', className: 'dsh-wt-error' }, metadata.error), h(Button, { onClick: () => { void projects.refresh(); } }, t('refresh'))),
|
|
1132
|
+
h('div', { className: 'dsh-wt-project-list', role: 'tree', 'aria-label': t('projects') }, ...groups.map(group => h(ProjectGroup, { key: group.project?.id || 'unassigned', group, selectedId, runtime: ctx, metadata, renderSlot, t, searching: !!query.trim(), onManage: openEditor, now }))),
|
|
1133
|
+
!metadata.projects.length && !metadata.loading && h('p', { className: 'dsh-wt-muted' }, t('noProjects')), editor && h(ProjectEditor, { key: editor.project?.id || 'new-project', project: editor.project, projects, runtime: ctx, t, close: () => setEditor(null), returnFocus: editorTrigger, fallbackFocus: newProjectTrigger }));
|
|
1134
|
+
}
|
|
1135
|
+
function NewWorktreeControls({ sessionId, flow, projects, repositories, runtime: ctx, t, useSession, useInput, usePanelInfo, useWorkspaces }) {
|
|
1136
|
+
const snapshot = useSession(value => value), empty = useInput(inputEmpty), ready = sourceEligible(snapshot), panel = usePanelInfo(value => value.activePanelId), workspaces = useWorkspaces(value => value);
|
|
1137
|
+
const state = React.useSyncExternalStore(flow.store.subscribe, flow.store.getSnapshot);
|
|
1138
|
+
React.useSyncExternalStore(repositories.store.subscribe, repositories.store.getSnapshot);
|
|
1139
|
+
const metadata = React.useSyncExternalStore(projects.store.subscribe, projects.store.getSnapshot), context = projects.context(sessionId);
|
|
1140
|
+
const [options, setOptions] = React.useState(false), [folderError, setFolderError] = React.useState('');
|
|
1141
|
+
React.useEffect(() => { setOptions(false); if (panel || !snapshot.blank || snapshot.subagent !== null) return; try { flow.observe(sessionId); } catch { /* New mode reports the guarded ABI refusal. */ } void repositories.ensure(sessionId); return () => repositories.withdraw(sessionId); }, [flow, repositories, sessionId, ready, panel, snapshot.blank, snapshot.subagent, snapshot.openState, snapshot.removed, context?.project.id, context?.folder.id, context?.folder.path]);
|
|
1142
|
+
if (panel || !snapshot.blank || snapshot.subagent !== null) return null;
|
|
1143
|
+
const backing = projectBacking(metadata, workspaces, sessionId), worktree = backing.mode === 'worktree', record = backing.record;
|
|
1144
|
+
const selected = state.sessionId === sessionId, busy = selected && state.busy, main = projectMainFolder(context?.project), canWorktree = repositories.enabled(sessionId), blockedNew = selected && state.mode === 'new' && (!canWorktree || state.sourceChanged);
|
|
1145
|
+
return h('div', { className: 'dsh-wt dsh-wt-row dsh-wt-create-controls' }, h(Styles),
|
|
1146
|
+
context?.project.folders.length > 1 && h('select', { className: 'dsh-wt-conversation-folder', 'aria-label': t('conversationFolder'), value: context.folder.id, disabled: busy || !empty || !sourceEligible(snapshot), title: !empty ? t('draftWarning') : context.folder.path,
|
|
1147
|
+
onChange: event => { setFolderError(''); try { chooseConversationFolder(ctx, projects, flow, sessionId, event.target.value); } catch (failure) { setFolderError(report(t, failure)); } } }, ...context.project.folders.map(folder => h('option', { key: folder.id, value: folder.id, title: folder.path }, folder.title + (folder.id === main?.id ? ' · ' + t('main') : '')))),
|
|
1148
|
+
folderError && h('span', { className: 'dsh-wt-create-error', role: 'alert' }, folderError),
|
|
1149
|
+
!blockedNew && (canWorktree || worktree) ? h('select', { 'aria-label': t('threadMode'), value: canWorktree && selected && state.mode === 'new' ? 'new' : worktree ? 'worktree' : 'local', disabled: busy || !sourceEligible(snapshot),
|
|
1150
|
+
onChange: event => { setOptions(false); flow.select(sessionId, event.target.value); } },
|
|
1151
|
+
h('option', { value: 'local', disabled: worktree && !context?.folder }, t('local')),
|
|
1152
|
+
worktree && h('option', { value: 'worktree' }, t('worktreeThread')),
|
|
1153
|
+
canWorktree && h('option', { value: 'new' }, t('new'))) : h(Button, { 'aria-label': t(worktree && !context ? 'keepCurrentFolder' : 'local'), 'aria-pressed': !selected || state.mode === 'local', disabled: busy || !sourceEligible(snapshot), onClick: () => flow.select(sessionId, 'local') }, t(worktree && !context ? 'keepCurrentFolder' : 'local')),
|
|
1154
|
+
worktree && h('span', { className: 'dsh-wt-muted', title: backing.effectiveCwd }, record?.branch || t('worktreeThread')),
|
|
1155
|
+
!blockedNew && canWorktree && !worktree && h(Button, { disabled: busy || !sourceEligible(snapshot), 'aria-expanded': options, 'aria-label': t('branch'), onClick: () => { setOptions(value => !value); if (!options) void flow.configure(sessionId); } }, selected && state.branch ? state.remote + '/' + state.branch : t('select')),
|
|
1156
|
+
!blockedNew && canWorktree && !worktree && options && h('fieldset', { className: 'dsh-wt-base-options', onKeyDown: event => { if (event.key === 'Escape') { event.stopPropagation(); setOptions(false); } } }, h('legend', null, t('branch')),
|
|
1157
|
+
selected && state.status?.remotes.length > 1 && h('select', { 'aria-label': t('remote'), disabled: busy || state.loading, value: state.remote, onChange: event => flow.setRemote(sessionId, event.target.value) }, ...state.status.remotes.map(item => h('option', { key: item.name, value: item.name }, item.name))),
|
|
1158
|
+
h('select', { 'aria-label': t('branch'), value: selected ? state.branch : '', disabled: !selected || state.loading || busy, onChange: event => flow.setBranch(event.target.value) }, h('option', { value: '' }, t(state.loading ? 'loading' : 'select')), ...(selected && state.branches?.items || []).map(item => h('option', { key: item.name, value: item.name }, item.name))),
|
|
1159
|
+
h('input', { type: 'search', 'aria-label': t('search'), value: selected ? state.query : '', disabled: !selected || busy || !state.remote, onChange: event => { void flow.loadBranches(sessionId, event.target.value); } }),
|
|
1160
|
+
selected && state.branches?.nextCursor && h(Button, { disabled: state.loading || busy, onClick: () => { void flow.loadBranches(sessionId, state.query, true); } }, t('more')),
|
|
1161
|
+
h(Button, { disabled: busy || state.loading, onClick: () => { void flow.configure(sessionId); } }, t('refresh')), h(Button, { onClick: () => setOptions(false) }, t('close'))),
|
|
1162
|
+
selected && state.error && h('span', { className: 'dsh-wt-create-error', role: 'alert' }, state.error,
|
|
1163
|
+
state.op && h(React.Fragment, null, ' · ', h('code', null, state.op.id), h(Button, { disabled: state.loading, onClick: () => { void flow.reconcile(sessionId); } }, t('reconcile')), state.op.result?.sessionId && h(Button, { onClick: () => flow.openTarget(sessionId) }, t('open')))));
|
|
1164
|
+
}
|
|
1165
|
+
function SetupProgress({ sessionId, flow, t }) {
|
|
1166
|
+
const state = React.useSyncExternalStore(flow.store.subscribe, flow.store.getSnapshot);
|
|
1167
|
+
if (state.sessionId !== sessionId || (!state.busy && !state.error && !state.steps.length)) return null;
|
|
1168
|
+
const labels = { preparing: 'preparingMessage', fetching: 'fetchingLatest', creating: 'creatingWorktree', naming: 'namingBranch', opening: 'openingConversation', ready: 'setupReady', attachments: 'stagingAttachments', sending: 'startingConversation', complete: 'setupReady', failed: 'setupFailed' };
|
|
1169
|
+
const active = state.phase, failed = active === 'failed' ? state.failedAt : undefined;
|
|
1170
|
+
const completed = new Set(state.steps.filter(step => step.stage !== failed).map(step => step.stage));
|
|
1171
|
+
return h('section', { className: 'dsh-wt dsh-wt-progress', 'aria-label': t('setupProgress'), 'aria-busy': state.busy }, h(Styles),
|
|
1172
|
+
h('div', { className: 'dsh-wt-row dsh-wt-between' }, h('strong', { role: 'status', 'aria-live': 'polite' }, t(labels[active] || 'setupProgress')), state.busy && h(Button, { onClick: flow.cancel }, t('cancelSetup'))),
|
|
1173
|
+
h('ol', null, ...['fetching', 'creating', 'naming'].map(stage => h('li', { key: stage, 'data-active': active === stage, 'aria-current': active === stage ? 'step' : undefined }, h('span', { 'aria-hidden': true }, failed === stage ? '× ' : completed.has(stage) && active !== stage ? '✓ ' : active === stage ? '◌ ' : '○ '), t(labels[stage])))),
|
|
1174
|
+
state.error && h('p', { role: 'alert', className: 'dsh-wt-error' }, state.error),
|
|
1175
|
+
!state.busy && state.op && h('div', { className: 'dsh-wt-row' }, h('code', null, state.op.id), h(Button, { onClick: () => { void flow.reconcile(sessionId); } }, t('reconcile')), state.op.result?.sessionId && h(Button, { onClick: () => flow.openTarget(sessionId) }, t('open'))));
|
|
1176
|
+
}
|
|
1177
|
+
// Keep the header passive: worktree details are loaded over quiet RPC only
|
|
1178
|
+
// after the user opens the manager, never on an ordinary conversation mount.
|
|
1179
|
+
function RootFolderSettings({ view, form, t }) {
|
|
1180
|
+
if (view === 'summary') return h('span', null, t('rootSummary'));
|
|
1181
|
+
return h(RootFolderSettingsPage, { form, t });
|
|
1182
|
+
}
|
|
1183
|
+
function RootFolderSettingsPage({ form, t }) {
|
|
1184
|
+
const state = form?.state, value = rootFolderValue(state);
|
|
1185
|
+
const [draft, setRootDraft] = React.useState(value), [revision, setRevision] = React.useState(state?.revision), [dirty, setDirty] = React.useState(false);
|
|
1186
|
+
const [busy, setBusy] = React.useState(false), [error, setError] = React.useState(''), [saved, setSaved] = React.useState(false);
|
|
1187
|
+
const pendingSave = React.useRef(null), alive = React.useRef(true), field = React.useId(), hint = React.useId();
|
|
1188
|
+
React.useEffect(() => () => { alive.current = false; }, []);
|
|
1189
|
+
React.useEffect(() => {
|
|
1190
|
+
if (!dirty || (pendingSave.current && state?.revision !== pendingSave.current.revision && (pendingSave.current.reset || value === pendingSave.current.root))) {
|
|
1191
|
+
setRootDraft(value); setRevision(state?.revision); setDirty(false); pendingSave.current = null;
|
|
1192
|
+
}
|
|
1193
|
+
}, [value, state?.revision, dirty]);
|
|
1194
|
+
const writable = state?.status === 'ready' && state.writable && state.mode === 'host';
|
|
1195
|
+
async function save(reset = false) {
|
|
1196
|
+
if (busy) return; setBusy(true); setError(''); setSaved(false);
|
|
1197
|
+
try {
|
|
1198
|
+
const root = await writeRootFolder(form, draft, revision, reset);
|
|
1199
|
+
if (alive.current) { pendingSave.current = { root, reset, revision }; if (reset || root === value) setDirty(false); setSaved(true); }
|
|
1200
|
+
} catch (failure) { if (alive.current) setError(report(t, failure)); }
|
|
1201
|
+
finally { if (alive.current) setBusy(false); }
|
|
1202
|
+
}
|
|
1203
|
+
return h('section', { className: 'dsh-wt dsh-wt-card', 'aria-label': t('rootFolder') }, h(Styles),
|
|
1204
|
+
h('form', { onSubmit: event => { event.preventDefault(); void save(); } },
|
|
1205
|
+
h('label', { className: 'dsh-wt-field', htmlFor: field }, t('rootFolder'), h('input', { id: field, value: draft, maxLength: 4096, disabled: !writable || busy, 'aria-describedby': hint, onChange: event => { setRootDraft(event.target.value); setDirty(true); setSaved(false); setError(''); } })),
|
|
1206
|
+
h('p', { id: hint, className: 'dsh-wt-muted' }, t('rootHelp')),
|
|
1207
|
+
h('div', { className: 'dsh-wt-row' }, h('button', { type: 'submit', disabled: !writable || busy || !dirty }, t(busy ? 'loading' : 'save')), h(Button, { disabled: !writable || busy, onClick: () => { void save(true); } }, t('rootReset'))),
|
|
1208
|
+
!writable && h('p', { role: 'status', className: 'dsh-wt-muted' }, t(state?.status === 'loading' ? 'loading' : 'rootUnavailable')),
|
|
1209
|
+
saved && h('p', { role: 'status', 'aria-live': 'polite', className: 'dsh-wt-muted' }, t('rootSaved')),
|
|
1210
|
+
error && h('p', { role: 'alert', className: 'dsh-wt-error' }, error)));
|
|
1211
|
+
}
|
|
1212
|
+
function Header({ projects, sessionId, t, useSession, useWorkspaces }) {
|
|
1213
|
+
const blank = useSession(value => value.blank), workspaces = useWorkspaces(value => value);
|
|
1214
|
+
const metadata = React.useSyncExternalStore(projects.store.subscribe, projects.store.getSnapshot), context = projects.context(sessionId), backing = projectBacking(metadata, workspaces, sessionId);
|
|
1215
|
+
if (blank || (!context && backing.mode !== 'worktree')) return null;
|
|
1216
|
+
const worktree = backing.mode === 'worktree', record = backing.record;
|
|
1217
|
+
const label = [context?.project.title, t(worktree ? 'worktreeThread' : 'local'), record?.branch].filter(Boolean).join(' · ');
|
|
1218
|
+
return h('span', { className: 'dsh-wt dsh-wt-muted', title: backing.effectiveCwd }, h(Styles), label);
|
|
1219
|
+
}
|
|
1220
|
+
function Manager({ runtime: ctx, projects, t, useSessions, usePanelInfo, useSessionStatus }) {
|
|
1221
|
+
const list = useSessions(value => value);
|
|
1222
|
+
const activePanel = usePanelInfo(value => value.activePanelId);
|
|
1223
|
+
const sessionStatuses = useSessionStatus(value => value);
|
|
1224
|
+
const actorId = currentActor(list);
|
|
1225
|
+
const [records, setRecords] = React.useState([]);
|
|
1226
|
+
const [cursor, setCursor] = React.useState(undefined);
|
|
1227
|
+
const [archived, setArchived] = React.useState(false);
|
|
1228
|
+
const [error, setError] = React.useState('');
|
|
1229
|
+
const [loading, setLoading] = React.useState(false);
|
|
1230
|
+
const [revision, setRevision] = React.useState(0);
|
|
1231
|
+
const [previewRecord, setPreviewRecord] = React.useState(null);
|
|
1232
|
+
const lifetime = React.useRef(null);
|
|
1233
|
+
const generation = React.useRef(0);
|
|
1234
|
+
React.useEffect(() => {
|
|
1235
|
+
const controller = new AbortController(); lifetime.current = controller;
|
|
1236
|
+
const seq = ++generation.current;
|
|
1237
|
+
setRecords([]); setCursor(undefined); setError('');
|
|
1238
|
+
if (!actorId || activePanel !== PANEL) return () => controller.abort();
|
|
1239
|
+
setLoading(true);
|
|
1240
|
+
requestHost(ctx, actorId, { action: 'list', includeArchived: archived, limit: 50 }, controller.signal).then(data => {
|
|
1241
|
+
if (seq !== generation.current || controller.signal.aborted) return;
|
|
1242
|
+
if (!Array.isArray(data?.items)) throw new WorktreeError('decode', 'invalid-list');
|
|
1243
|
+
setRecords(data.items); projects?.rememberRecords(data.items); setCursor(data.nextCursor);
|
|
1244
|
+
}).catch(failure => { if (seq === generation.current && !controller.signal.aborted) setError(report(t, failure)); }).finally(() => { if (seq === generation.current && !controller.signal.aborted) setLoading(false); });
|
|
1245
|
+
return () => controller.abort();
|
|
1246
|
+
}, [ctx, actorId, archived, revision, activePanel]);
|
|
1247
|
+
async function more() {
|
|
1248
|
+
const controller = lifetime.current; const seq = generation.current;
|
|
1249
|
+
setLoading(true);
|
|
1250
|
+
try {
|
|
1251
|
+
const data = await requestHost(ctx, actorId, { action: 'list', includeArchived: archived, cursor, limit: 50 }, controller.signal);
|
|
1252
|
+
if (controller.signal.aborted || seq !== generation.current) return;
|
|
1253
|
+
setRecords(previous => [...previous, ...data.items.filter(item => !previous.some(record => record.id === item.id))]); setCursor(data.nextCursor);
|
|
1254
|
+
} catch (failure) { if (!controller.signal.aborted && seq === generation.current) setError(report(t, failure)); }
|
|
1255
|
+
finally { if (!controller.signal.aborted && seq === generation.current) setLoading(false); }
|
|
1256
|
+
}
|
|
1257
|
+
return h('section', { className: 'dsh-wt dsh-wt-panel', 'aria-label': t('title') }, h(Styles),
|
|
1258
|
+
h('div', { className: 'dsh-wt-row dsh-wt-between' }, h('h2', null, t('title')), h(Button, { onClick: () => ctx.layout.selectPanel(null) }, t('close'))),
|
|
1259
|
+
h('p', { className: 'dsh-wt-muted' }, t('retained')),
|
|
1260
|
+
!actorId ? h('p', { role: 'status' }, t('actor')) : h(React.Fragment, null,
|
|
1261
|
+
h('div', { className: 'dsh-wt-row' }, h(Button, { disabled: loading, onClick: () => setRevision(value => value + 1) }, t('refresh')),
|
|
1262
|
+
h('label', null, h('input', { type: 'checkbox', checked: archived, onChange: event => setArchived(event.target.checked) }), t('includeArchived'))),
|
|
1263
|
+
loading && h('p', { role: 'status' }, t('loading')),
|
|
1264
|
+
!loading && records.length === 0 && h('p', null, t('empty')),
|
|
1265
|
+
...records.map(record => h(RecordCard, { key: record.id, record, runtime: ctx, t, actorId, list, sessionStatuses, onChanged: () => setRevision(value => value + 1), onPreview: () => setPreviewRecord(record) })),
|
|
1266
|
+
cursor && h(Button, { disabled: loading, onClick: more }, t('more'))),
|
|
1267
|
+
error && h('p', { role: 'alert', className: 'dsh-wt-error' }, error),
|
|
1268
|
+
previewRecord && h(Handoff, { key: previewRecord.id + ':' + actorId, record: previewRecord, actorId, runtime: ctx, t, close: () => setPreviewRecord(null) }));
|
|
1269
|
+
}
|
|
1270
|
+
function RecordCard({ record, runtime: ctx, t, actorId, list, sessionStatuses, onChanged, onPreview }) {
|
|
1271
|
+
const [checkout, setCheckout] = React.useState(null);
|
|
1272
|
+
const [error, setError] = React.useState('');
|
|
1273
|
+
const [busy, setBusy] = React.useState(false);
|
|
1274
|
+
const [name, setName] = React.useState('');
|
|
1275
|
+
const [branchDialog, setBranchDialog] = React.useState(false);
|
|
1276
|
+
const [operationStatus, setOperationStatus] = React.useState(null);
|
|
1277
|
+
const controller = React.useRef(null);
|
|
1278
|
+
React.useEffect(() => {
|
|
1279
|
+
const abort = new AbortController(); controller.current = abort;
|
|
1280
|
+
requestHost(ctx, actorId, { action: 'status', id: record.id }, abort.signal).then(data => { if (!abort.signal.aborted) setCheckout(data.status); }).catch(failure => { if (!abort.signal.aborted) setError(t('statusError') + ': ' + report(t, failure)); });
|
|
1281
|
+
return () => abort.abort();
|
|
1282
|
+
}, [ctx, actorId, record]);
|
|
1283
|
+
async function act(request, open) {
|
|
1284
|
+
const signal = controller.current.signal;
|
|
1285
|
+
const navigation = open ? ctx.layout.beginNavigation() : null;
|
|
1286
|
+
setBusy(true); setError('');
|
|
1287
|
+
try {
|
|
1288
|
+
const data = await requestHost(ctx, actorId, { ...request, id: record.id }, signal);
|
|
1289
|
+
if (signal.aborted) return;
|
|
1290
|
+
if (open) {
|
|
1291
|
+
if (!data?.sessionId) throw new WorktreeError('invalidSession', 'missing-exact-session');
|
|
1292
|
+
if (!navigation.aborted && currentActor(ctx.sessions.list.getSnapshot()) === actorId) ctx.uiWorkspace.openSession(data.sessionId);
|
|
1293
|
+
} else { setBranchDialog(false); onChanged(); }
|
|
1294
|
+
} catch (failure) { if (!signal.aborted) setError(report(t, failure)); }
|
|
1295
|
+
finally { if (!signal.aborted) setBusy(false); }
|
|
1296
|
+
}
|
|
1297
|
+
return h('article', { className: 'dsh-wt-card' },
|
|
1298
|
+
h('div', { className: 'dsh-wt-row dsh-wt-between' }, h('h3', null, record.branch || t('detached')), h('span', { className: 'dsh-wt-muted' }, t(record.protected ? 'protected' : 'unprotected') + (record.archived ? ' · ' + t('archived') : ''))),
|
|
1299
|
+
h('dl', { className: 'dsh-wt-details' },
|
|
1300
|
+
h('dt', null, t('base')), h('dd', null, record.remote + '/' + record.remoteBranch + ' · ' + record.baseOid.slice(0, 12)),
|
|
1301
|
+
h('dt', null, t('state')), h('dd', null, record.state + (checkout ? ' · ' + t(checkout.dirty ? 'dirty' : 'clean') + ' · ' + checkout.changes + ' / ' + checkout.untracked : '')),
|
|
1302
|
+
h('dt', null, t('local')), h('dd', null, h('code', null, record.effectiveCwd)),
|
|
1303
|
+
h('dt', null, t('sessions')), h('dd', null, ...record.sessionIds.map(id => h('div', { key: id, className: 'dsh-wt-row' }, h(Button, { onClick: () => ctx.uiWorkspace.openSession(id) }, list.byId[id]?.displayTitle || id), h('span', { className: 'dsh-wt-muted' }, t(sessionStatuses.get(id)?.running ? 'busy' : 'idle')))))),
|
|
1304
|
+
record.error && h('p', { role: 'alert', className: 'dsh-wt-error' }, record.error),
|
|
1305
|
+
h('div', { className: 'dsh-wt-row' },
|
|
1306
|
+
h(Button, { disabled: busy || record.archived || record.state !== 'ready', onClick: () => act({ action: 'start', sourceSessionId: actorId, sessionMode: 'continue' }, true) }, t('open')),
|
|
1307
|
+
h(Button, { disabled: busy || record.archived, onClick: () => setBranchDialog(true) }, t('createBranch')),
|
|
1308
|
+
h(Button, { disabled: busy, onClick: () => act({ action: 'protect', protected: !record.protected }) }, t(record.protected ? 'unprotect' : 'protect')),
|
|
1309
|
+
h(Button, { disabled: busy, onClick: () => act({ action: 'archive', archived: !record.archived }) }, t(record.archived ? 'restore' : 'archive')),
|
|
1310
|
+
h(Button, { disabled: busy || record.state !== 'ready' || !record.sessionIds.includes(actorId), onClick: onPreview }, t('preview'))),
|
|
1311
|
+
!record.sessionIds.includes(actorId) && h('p', { className: 'dsh-wt-muted' }, t('openSource')),
|
|
1312
|
+
h('p', { className: 'dsh-wt-muted' }, t('operation') + ': ', h('code', null, record.operationId)),
|
|
1313
|
+
h(Button, { disabled: busy, onClick: async () => {
|
|
1314
|
+
const signal = controller.current.signal;
|
|
1315
|
+
try { const data = await requestHost(ctx, actorId, { action: 'status', operationId: record.operationId }, signal); if (!signal.aborted) setOperationStatus(data); }
|
|
1316
|
+
catch (failure) { if (!signal.aborted) setError(report(t, failure)); }
|
|
1317
|
+
} }, t('reconcile')),
|
|
1318
|
+
operationStatus && h('p', { role: 'status' }, t('state') + ': ' + operationStatus.operation.phase),
|
|
1319
|
+
error && h('p', { role: 'alert', className: 'dsh-wt-error' }, error),
|
|
1320
|
+
branchDialog && h(Dialog, { title: t('createBranch'), t, close: () => setBranchDialog(false) }, h('form', { onSubmit: event => { event.preventDefault(); void act({ action: 'branch', name }); } },
|
|
1321
|
+
h('label', { className: 'dsh-wt-field' }, t('branchName'), h('input', { value: name, required: true, onChange: event => setName(event.target.value) })),
|
|
1322
|
+
h('button', { type: 'submit', disabled: busy || !name.trim() }, t('createBranch')))));
|
|
1323
|
+
}
|
|
1324
|
+
function Handoff({ record, actorId, runtime: ctx, t, close }) {
|
|
1325
|
+
const [preview, setPreview] = React.useState(null);
|
|
1326
|
+
const [exported, setExported] = React.useState(null);
|
|
1327
|
+
const [targetPath, setTargetPath] = React.useState('');
|
|
1328
|
+
const [noWriters, setNoWriters] = React.useState(false);
|
|
1329
|
+
const [error, setError] = React.useState('');
|
|
1330
|
+
const [busy, setBusy] = React.useState(false);
|
|
1331
|
+
const [operationId, setOperationId] = React.useState(null);
|
|
1332
|
+
const [operationStatus, setOperationStatus] = React.useState(null);
|
|
1333
|
+
const [copied, setCopied] = React.useState(false);
|
|
1334
|
+
const lifetime = React.useRef(new AbortController());
|
|
1335
|
+
const request = React.useRef(null);
|
|
1336
|
+
const seq = React.useRef(0);
|
|
1337
|
+
React.useEffect(() => () => { lifetime.current.abort(); request.current?.abort(); }, []);
|
|
1338
|
+
function changed(value) { request.current?.abort(); ++seq.current; setTargetPath(value); setPreview(null); setExported(null); setNoWriters(false); setBusy(false); setOperationId(null); }
|
|
1339
|
+
async function loadPreview() {
|
|
1340
|
+
if (!actorId) { setError(t('actor')); return; }
|
|
1341
|
+
request.current?.abort(); const controller = new AbortController(); request.current = controller;
|
|
1342
|
+
const generation = ++seq.current; setBusy(true); setPreview(null); setExported(null); setError(''); setNoWriters(false); setOperationId(null); setOperationStatus(null);
|
|
1343
|
+
try {
|
|
1344
|
+
const data = await requestHost(ctx, actorId, { action: 'preview', id: record.id, sourceSessionId: actorId, ...(targetPath.trim() ? { targetPath: targetPath.trim() } : {}) }, controller.signal);
|
|
1345
|
+
if (lifetime.current.signal.aborted || controller.signal.aborted || generation !== seq.current) return;
|
|
1346
|
+
if (!data?.id || data.worktreeId !== record.id || !Array.isArray(data.files)) throw new WorktreeError('decode', 'invalid-preview');
|
|
1347
|
+
setPreview(data);
|
|
1348
|
+
} catch (failure) { if (!lifetime.current.signal.aborted && !controller.signal.aborted && generation === seq.current) setError(report(t, failure)); }
|
|
1349
|
+
finally { if (!lifetime.current.signal.aborted && !controller.signal.aborted && generation === seq.current) setBusy(false); }
|
|
1350
|
+
}
|
|
1351
|
+
async function exportPatch() {
|
|
1352
|
+
const saved = preview; const generation = seq.current;
|
|
1353
|
+
setBusy(true);
|
|
1354
|
+
try {
|
|
1355
|
+
const data = await requestHost(ctx, actorId, { action: 'export', previewId: saved.id }, lifetime.current.signal);
|
|
1356
|
+
if (lifetime.current.signal.aborted || generation !== seq.current) return;
|
|
1357
|
+
if (typeof data?.path !== 'string' || data.patchHash !== saved.patchHash) throw new WorktreeError('decode', 'export-preview-mismatch');
|
|
1358
|
+
setExported(data);
|
|
1359
|
+
} catch (failure) { if (!lifetime.current.signal.aborted && generation === seq.current) setError(report(t, failure)); }
|
|
1360
|
+
finally { if (!lifetime.current.signal.aborted && generation === seq.current) setBusy(false); }
|
|
1361
|
+
}
|
|
1362
|
+
async function apply() {
|
|
1363
|
+
if (!noWriters || !preview || busy || currentActor(ctx.sessions.list.getSnapshot()) !== actorId) return;
|
|
1364
|
+
const navigation = ctx.layout.beginNavigation();
|
|
1365
|
+
const controller = new AbortController(); request.current?.abort(); request.current = controller;
|
|
1366
|
+
const generation = ++seq.current;
|
|
1367
|
+
const id = operationId || crypto.randomUUID(); setOperationId(id); setBusy(true); setError('');
|
|
1368
|
+
const cancel = () => controller.abort(); navigation.addEventListener('abort', cancel, { once: true });
|
|
1369
|
+
try {
|
|
1370
|
+
const data = await requestHost(ctx, actorId, { action: 'handoff', id: record.id, previewId: preview.id, operationId: id }, controller.signal);
|
|
1371
|
+
if (controller.signal.aborted || lifetime.current.signal.aborted || navigation.aborted || generation !== seq.current || currentActor(ctx.sessions.list.getSnapshot()) !== actorId) return;
|
|
1372
|
+
if (!data?.sessionId) throw new WorktreeError('invalidSession', 'missing-exact-local-session');
|
|
1373
|
+
ctx.uiWorkspace.openSession(data.sessionId);
|
|
1374
|
+
close();
|
|
1375
|
+
} catch (failure) { if (!lifetime.current.signal.aborted && generation === seq.current) setError(report(t, failure)); }
|
|
1376
|
+
finally { navigation.removeEventListener('abort', cancel); if (!lifetime.current.signal.aborted && generation === seq.current) setBusy(false); }
|
|
1377
|
+
}
|
|
1378
|
+
async function reconcile() {
|
|
1379
|
+
try { const data = await requestHost(ctx, actorId, { action: 'status', operationId }, lifetime.current.signal); if (!lifetime.current.signal.aborted) setOperationStatus(data); }
|
|
1380
|
+
catch (failure) { if (!lifetime.current.signal.aborted) setError(report(t, failure)); }
|
|
1381
|
+
}
|
|
1382
|
+
return h(Dialog, { title: t('previewTitle'), t, close: () => { request.current?.abort(); close(); } },
|
|
1383
|
+
h('p', { className: 'dsh-wt-muted' }, t('handoffWarning')),
|
|
1384
|
+
h('label', { className: 'dsh-wt-field' }, t('target'), h('input', { value: targetPath, disabled: busy, placeholder: t('original'), onChange: event => changed(event.target.value) })),
|
|
1385
|
+
h(Button, { disabled: busy || !actorId, onClick: loadPreview }, t('preview')),
|
|
1386
|
+
preview && h(React.Fragment, null,
|
|
1387
|
+
h('dl', { className: 'dsh-wt-details' }, h('dt', null, t('target')), h('dd', null, h('code', null, preview.targetRoot)), h('dt', null, t('base')), h('dd', null, h('code', null, preview.baseOid)), h('dt', null, t('bytes')), h('dd', null, preview.bytes), h('dt', null, t('patch')), h('dd', null, h('code', null, exported?.path || preview.patchPath))),
|
|
1388
|
+
h('h3', null, t('files')), h('ul', null, ...preview.files.map(file => h('li', { key: file.path }, file.status + ' · ' + file.path + (file.binary ? ' · ' + t('binary') : '')))),
|
|
1389
|
+
h('div', { className: 'dsh-wt-row' }, h(Button, { disabled: busy, onClick: exportPatch }, t('export')), h(Button, { onClick: async () => { try { await navigator.clipboard.writeText(exported?.path || preview.patchPath); if (!lifetime.current.signal.aborted) setCopied(true); } catch (failure) { if (!lifetime.current.signal.aborted) setError(report(t, failure)); } } }, t(copied ? 'copied' : 'copy'))),
|
|
1390
|
+
exported?.patch && h('pre', null, exported.patch),
|
|
1391
|
+
h('label', null, h('input', { type: 'checkbox', checked: noWriters, disabled: busy, onChange: event => setNoWriters(event.target.checked) }), t('noWriters')),
|
|
1392
|
+
h(Button, { className: 'dsh-wt-primary', disabled: busy || !noWriters, onClick: apply }, t('apply'))),
|
|
1393
|
+
busy && h('p', { role: 'status' }, t('loading')),
|
|
1394
|
+
error && h('p', { role: 'alert', className: 'dsh-wt-error' }, error),
|
|
1395
|
+
operationId && h('div', null, h('p', null, t('operation') + ': ', h('code', null, operationId)), h(Button, { disabled: busy, onClick: reconcile }, t('reconcile')),
|
|
1396
|
+
operationStatus && h('div', null, h('p', null, t('state') + ': ' + operationStatus.operation.phase), operationStatus.operation.error && h('p', { role: 'alert' }, operationStatus.operation.error.code + ': ' + operationStatus.operation.error.message), operationStatus.sessionId && h(Button, { onClick: () => ctx.uiWorkspace.openSession(operationStatus.sessionId) }, t('open')))));
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
inject: ['slots', 'locale', 'conversation', 'sessions', 'uiSession', 'uiWorkspace', 'layout', 'connection', 'workspaces', 'shortcuts'],
|
|
1400
|
+
// Export pure protocol/guard functions for Node tests, not a second renderer or service.
|
|
1401
|
+
protocol: Object.freeze({ createRepositoryAvailability, projectMainFolder, draftMainFolder, chooseConversationFolder, rootFolderValue, writeRootFolder, decodeReply, validateData, currentActor, sourceEligible, inputEmpty, captureDraft, draftUnchanged, advertisedDefault, acquireBlock, NATIVE_SUBMIT_ABI, interrupted, leaseNativeSink, setupProgress, prepareWorktree, waitForDraftFiles, createWorktreeFlow, requestHost, absolutePath, projectRequest, projectData, requestProjects, workspaceStructure, projectContext, captureProject, sameProject, projectPath, projectRows, visibleProjectRows, projectActor, projectReady, createProjectStore, projectSlotName, projectChildren, mirrorProjectSlot, createSidebarMode }),
|
|
1402
|
+
apply(ctx) {
|
|
1403
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }));
|
|
1404
|
+
const projects = createProjectStore(ctx, ctx.locale.bind(NS));
|
|
1405
|
+
ctx.effect(() => () => projects.dispose());
|
|
1406
|
+
const repositories = createRepositoryAvailability(ctx, projects);
|
|
1407
|
+
ctx.effect(() => () => repositories.dispose());
|
|
1408
|
+
const flow = createWorktreeFlow(ctx, ctx.locale.bind(NS), projects, repositories);
|
|
1409
|
+
ctx.effect(() => () => flow.dispose());
|
|
1410
|
+
let sidebarMode;
|
|
1411
|
+
const seat = (owner, options, component) => ctx.slots.inject(owner, () => ctx.slots.register({ ...options, locale: NS, inject: () => ({ runtime: ctx, flow, projects, repositories, sidebarMode }) }, component));
|
|
1412
|
+
sidebarMode = createSidebarMode(() => seat('sidebar.workspaces', { name: 'sidebar.workspaces', priority: -50, children: projectChildren(ctx) }, ProjectsSidebar));
|
|
1413
|
+
ctx.effect(() => () => sidebarMode.dispose());
|
|
1414
|
+
for (const source of projectSlots) mirrorProjectSlot(ctx, source);
|
|
1415
|
+
registerSidebarViewSwitch(ctx, sidebarMode, SidebarToggle);
|
|
1416
|
+
seat('conversation.input.left', { name: 'conversation.input.left', id: PANEL, order: 15 }, NewWorktreeControls);
|
|
1417
|
+
seat('conversation.input.dock', { name: 'conversation.input.dock', id: PANEL + '-setup', order: 5 }, SetupProgress);
|
|
1418
|
+
seat('plugins.row.config', { name: 'plugins.row.config', key: '@1yefuwang1/dsh-worktrees#git-worktrees' }, RootFolderSettings);
|
|
1419
|
+
seat('conversation.session.header.actions', { name: 'conversation.session.header.actions', id: PANEL, order: 250 }, Header);
|
|
1420
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL, order: 450, label: () => ctx.locale.bind(NS)('title') }, Icon));
|
|
1421
|
+
seat('main', { name: 'main', key: PANEL }, Manager);
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
},
|
|
1425
|
+
});
|