@voltage12/dsh-simple-worktree 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/cordis.patch.yml +4 -0
- package/docs/create-dialog-worktree.png +0 -0
- package/docs/create-dialog.png +0 -0
- package/docs/created-workspace.png +0 -0
- package/docs/delete-dialog.png +0 -0
- package/docs/git-missing.png +0 -0
- package/docs/non-git-toast.png +0 -0
- package/lib/client.js +727 -0
- package/lib/index.js +389 -0
- package/package.json +83 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xiejinyang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @voltage12/dsh-simple-worktree
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH Web 插件(非官方):在会话输入框工具行一键创建 git worktree,并在会话「…」菜单里把它连同工作区一起收掉。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
### 新建 Worktree
|
|
10
|
+
|
|
11
|
+
- 入口在 `conversation.input.left`(模型选择、权限预设那一排),是一个无底色、28×28 的分支图标按钮。
|
|
12
|
+
- 按钮始终可点;当前会话目录不是 git 仓库、或系统里找不到 git 命令时,点击只弹出一条提示,不会打开创建窗口。
|
|
13
|
+
- 弹窗两个互斥选项:
|
|
14
|
+
- **新建分支**(默认):分支名输入框 + 「基于分支」下拉框(默认当前分支)。
|
|
15
|
+
- **已有分支**:列出全部本地分支,无默认;已被其他 worktree 检出的分支置灰并标注。
|
|
16
|
+
- 创建位置固定为**主仓库根目录** `<repo>/.agents/worktree/<分支名>`,分支名里的 `/` 拍平成 `-`。
|
|
17
|
+
- 目录重名自动加 `-2`、`-3`… 后缀;分支重名直接报错。
|
|
18
|
+
- 自动创建 `.agents/worktree/` 目录,并确保仓库根 `.gitignore` 含 `.agents/worktree/`。
|
|
19
|
+
- 创建成功后注册为 Workspace,在其中新建 Session 并自动激活。
|
|
20
|
+
- 如果当前会话本身就在 worktree 中(不是主仓库),弹窗底部会多出「删除当前 Worktree」;它与「…」菜单里的「删除 Worktree」走**完全相同**的确认、归档、注销、删除流程。
|
|
21
|
+
- git 失败时弹窗保留、显示原始错误,可改名重试。
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
### 删除 Worktree
|
|
26
|
+
|
|
27
|
+
- 入口在侧边栏会话行的「…」菜单:`sidebar.workspaces.session.menu.item`。
|
|
28
|
+
- **常驻显示**:主仓库会话也会出现;不在 worktree 中时点击只弹提示,不执行删除。
|
|
29
|
+
- 弹窗会列出:
|
|
30
|
+
- 该工作区下将被归档的会话数(含当前会话);
|
|
31
|
+
- workspace 注册将被移除;
|
|
32
|
+
- worktree 目录(未提交改动会丢失);
|
|
33
|
+
- 未提交改动数、未推送提交数;
|
|
34
|
+
- 分支会保留。
|
|
35
|
+
- 确认后按顺序执行:归档会话 → 注销 Workspace → `git worktree remove --force` 并清理残留目录。
|
|
36
|
+
- 有会话正在运行时拒绝删除并提示。
|
|
37
|
+
- 分支**不会**被删除。
|
|
38
|
+
|
|
39
|
+
> DSH 的会话只能归档、不能物理删除;这里的“删除会话”即归档。
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
## 安装
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
dsh plugin --profile desktop add @voltage12/dsh-simple-worktree
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
插件声明了 `dsh.bundle`,`dsh plugin add` 会把它加进 profile 的 bundles 列表。**装完需要重启 DeepSeek Harness**(host 半边在启动时组合)。
|
|
50
|
+
|
|
51
|
+
如果 npm 镜像尚未同步到新版本,可以临时指定官方源:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
dsh plugin --profile desktop add @voltage12/dsh-simple-worktree --registry=https://registry.npmjs.org/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
卸载:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
dsh plugin --profile desktop remove @voltage12/dsh-simple-worktree
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 兼容性
|
|
64
|
+
|
|
65
|
+
- 实测 DSH `0.2.1-rc.2`。
|
|
66
|
+
- 插件通过 `engines.dsh` 声明兼容范围 `>=0.2.1-rc.2 <0.3.0`。DSH 在安装和启动前会做兼容性检查,不匹配时会禁用该插件并提示,而不是直接崩溃。
|
|
67
|
+
- DSH 升级后请重新验证;如需放宽范围,会随插件新版本发布。
|
|
68
|
+
- 界面文案固定中文(中英文字典内容一致),不跟随应用语言切换。
|
|
69
|
+
|
|
70
|
+
## 安全说明
|
|
71
|
+
|
|
72
|
+
这是一个**非官方**插件,host 半边会:
|
|
73
|
+
|
|
74
|
+
- 调用 PATH 中的 `git` 命令;
|
|
75
|
+
- 在仓库根目录创建 `.agents/worktree/` 并修改仓库的 `.gitignore`;
|
|
76
|
+
- 在删除流程中移除 worktree 目录;
|
|
77
|
+
- 注册 `/plugin/simple-worktree/*` 下的本地 HTTP 路由。
|
|
78
|
+
|
|
79
|
+
只应在你信任的 DSH 安装中启用。不要把 DSH 的 Web 服务直接暴露到不可信网络;如果通过反向代理或隧道对外提供访问,请先确认 DSH 自身的访问控制。
|
|
80
|
+
|
|
81
|
+
## 开发
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
pnpm install
|
|
85
|
+
pnpm build # lib/index.js (host) + lib/client.js (web client)
|
|
86
|
+
pnpm typecheck
|
|
87
|
+
pnpm pack # 发布前构建并检查 tarball
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- host 半:`src/host/index.js`,四条 `/plugin/simple-worktree/*` 路由(probe/create/inspect/remove),只依赖 Node 内置模块。
|
|
91
|
+
- client 半:`src/client/index.tsx`,由 esbuild 打成 DSH 的 `window.__ModuleLoader__.load({ id, factory })` 结构;模块 id 直接取 `package.json` 的包名。
|
|
92
|
+
- `lib/` 是构建产物;改 client 后重启进程或刷新页面,改 host 后需要重启宿主。
|
|
93
|
+
|
|
94
|
+
## 已知边界
|
|
95
|
+
|
|
96
|
+
- worktree 只包含已提交内容,当前未提交改动不会带过去。
|
|
97
|
+
- workspace/session 注册失败时不回滚已经建好的 worktree,会报错并给出路径。
|
|
98
|
+
- 目录名冲突后缀最多尝试 200 次。
|
|
99
|
+
- **git 以「分支」为单位判断占用,不看 commit**:两个不同分支指向同一个 commit 时,各自都可以有 worktree;只有同一个分支已被某个 worktree 检出时才会被拒绝(git 自身也拒绝)。「新建分支」模式要求分支名尚不存在;如果要为已存在的分支建 worktree,请用「已有分支」模式。
|
|
100
|
+
- 插件依赖 PATH 中的 `git`;找不到 git 时,新建和删除两个入口点击后都会提示「未找到 git 命令」,不会静默失败。
|
|
101
|
+
- DSH 对还没有消息的 blank session 不渲染会话行「…」菜单,所以发第一条消息前看不到菜单里的「删除 Worktree」。但空白会话本身是真实存在的(有 session id、cwd 和持久化日志),可以先在新建弹窗底部用「删除当前 Worktree」删掉它。
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@voltage12/dsh-simple-worktree",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
"use strict";
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name2 in all)
|
|
13
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
24
|
+
|
|
25
|
+
// src/client/index.tsx
|
|
26
|
+
var index_exports = {};
|
|
27
|
+
__export(index_exports, {
|
|
28
|
+
apply: () => apply,
|
|
29
|
+
inject: () => inject,
|
|
30
|
+
name: () => name
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
var import_react = require("react");
|
|
34
|
+
var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
35
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
36
|
+
var name = "simple-worktree";
|
|
37
|
+
var inject = ["slots", "sessions", "workspaces", "uiWorkspace", "locale"];
|
|
38
|
+
var ROUTE_PREFIX = "/plugin/simple-worktree";
|
|
39
|
+
var NS = "simpleWorktree";
|
|
40
|
+
var zh = {
|
|
41
|
+
button: "Worktree",
|
|
42
|
+
buttonTitle: "\u65B0\u5EFA Worktree",
|
|
43
|
+
checking: "\u6B63\u5728\u68C0\u67E5 Git \u4ED3\u5E93\u2026",
|
|
44
|
+
notRepo: "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93",
|
|
45
|
+
gitMissing: "\u672A\u627E\u5230 git \u547D\u4EE4\uFF0C\u8BF7\u5148\u5B89\u88C5 Git \u5E76\u52A0\u5165 PATH",
|
|
46
|
+
createTitle: "\u65B0\u5EFA Worktree",
|
|
47
|
+
modeNew: "\u65B0\u5EFA\u5206\u652F",
|
|
48
|
+
modeExisting: "\u5DF2\u6709\u5206\u652F",
|
|
49
|
+
branchName: "\u5206\u652F\u540D",
|
|
50
|
+
branchNamePlaceholder: "\u4F8B\u5982 feat/login",
|
|
51
|
+
baseBranch: "\u57FA\u4E8E\u5206\u652F",
|
|
52
|
+
chooseBranch: "\u8BF7\u9009\u62E9\u5206\u652F",
|
|
53
|
+
occupied: "\u5DF2\u88AB worktree \u5360\u7528",
|
|
54
|
+
target: "\u5C06\u521B\u5EFA\u5230",
|
|
55
|
+
cancel: "\u53D6\u6D88",
|
|
56
|
+
create: "\u521B\u5EFA",
|
|
57
|
+
creating: "\u521B\u5EFA\u4E2D\u2026",
|
|
58
|
+
close: "\u5173\u95ED",
|
|
59
|
+
invalidEmpty: "\u8BF7\u8F93\u5165\u5206\u652F\u540D",
|
|
60
|
+
invalidName: "\u5206\u652F\u540D\u4E0D\u5408\u6CD5",
|
|
61
|
+
invalidExists: "\u672C\u5730\u5DF2\u5B58\u5728\u540C\u540D\u5206\u652F\uFF0C\u5982\u9700\u4E3A\u5B83\u521B\u5EFA worktree \u8BF7\u6539\u7528\u300C\u5DF2\u6709\u5206\u652F\u300D",
|
|
62
|
+
invalidSame: "\u8D77\u70B9\u4E0D\u80FD\u4E0E\u5206\u652F\u540D\u76F8\u540C",
|
|
63
|
+
loadFailed: "\u8BFB\u53D6\u4ED3\u5E93\u4FE1\u606F\u5931\u8D25",
|
|
64
|
+
deleteMenu: "\u5220\u9664 Worktree",
|
|
65
|
+
deleteCurrent: "\u5220\u9664\u5F53\u524D Worktree",
|
|
66
|
+
deleteTitle: "\u5220\u9664 Worktree",
|
|
67
|
+
deleteWarning: "\u6B64\u64CD\u4F5C\u4F1A\u5220\u9664 worktree \u76EE\u5F55\uFF0C\u672A\u63D0\u4EA4\u7684\u6539\u52A8\u5C06\u6C38\u4E45\u4E22\u5931\u3002",
|
|
68
|
+
willArchive: "\u8BE5\u5DE5\u4F5C\u533A\u4E0B\u7684 {count} \u4E2A\u4F1A\u8BDD\u5C06\u88AB\u5F52\u6863\uFF08\u542B\u5F53\u524D\u4F1A\u8BDD\uFF09",
|
|
69
|
+
willRemoveWorkspace: "\u5DE5\u4F5C\u533A\u6CE8\u518C\u5C06\u88AB\u79FB\u9664",
|
|
70
|
+
willRemoveDir: "worktree \u76EE\u5F55\u5C06\u88AB\u5220\u9664\uFF1A",
|
|
71
|
+
dirty: "{count} \u4E2A\u672A\u63D0\u4EA4\u6539\u52A8",
|
|
72
|
+
ahead: "{count} \u4E2A\u672A\u63A8\u9001\u63D0\u4EA4",
|
|
73
|
+
branchKept: "\u5206\u652F {branch} \u4F1A\u4FDD\u7559\uFF0C\u4E0D\u4F1A\u88AB\u5220\u9664",
|
|
74
|
+
confirmDelete: "\u786E\u8BA4\u5220\u9664",
|
|
75
|
+
deleting: "\u5220\u9664\u4E2D\u2026",
|
|
76
|
+
reading: "\u8BFB\u53D6\u4E2D\u2026",
|
|
77
|
+
detachedHead: "\u6E38\u79BB HEAD",
|
|
78
|
+
notWorktree: "\u5F53\u524D\u4F1A\u8BDD\u4E0D\u5728 worktree \u4E2D",
|
|
79
|
+
running: "\u8BE5\u5DE5\u4F5C\u533A\u8FD8\u6709\u4F1A\u8BDD\u6B63\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u5220\u9664",
|
|
80
|
+
workspaceMissing: "\u627E\u4E0D\u5230\u8BE5\u4F1A\u8BDD\u6240\u5C5E\u7684\u5DE5\u4F5C\u533A",
|
|
81
|
+
deleted: "\u5DF2\u5220\u9664 Worktree\uFF1A{path}",
|
|
82
|
+
workspaceFailed: "worktree \u5DF2\u521B\u5EFA\uFF0C\u4F46\u6CE8\u518C\u5DE5\u4F5C\u533A\u5931\u8D25\uFF1A",
|
|
83
|
+
sessionFailed: "\u5DE5\u4F5C\u533A\u5DF2\u6CE8\u518C\uFF0C\u4F46\u65B0\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A"
|
|
84
|
+
};
|
|
85
|
+
var en = zh;
|
|
86
|
+
async function request(method, path, body, query) {
|
|
87
|
+
const search = query === void 0 ? "" : `?${new URLSearchParams(query).toString()}`;
|
|
88
|
+
const response = await fetch(`${ROUTE_PREFIX}${path}${search}`, {
|
|
89
|
+
method,
|
|
90
|
+
cache: "no-store",
|
|
91
|
+
headers: body === void 0 ? void 0 : { "content-type": "application/json" },
|
|
92
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
93
|
+
});
|
|
94
|
+
const data = await response.json().catch(() => ({}));
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
throw new Error(typeof data?.error === "string" ? data.error : `HTTP ${response.status}`);
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
function probeRepo(path) {
|
|
100
|
+
return request("GET", "/probe", void 0, { path });
|
|
101
|
+
}
|
|
102
|
+
function createOnDisk(input) {
|
|
103
|
+
return request("POST", "/create", input);
|
|
104
|
+
}
|
|
105
|
+
function inspectOnDisk(path) {
|
|
106
|
+
return request("POST", "/inspect", { path });
|
|
107
|
+
}
|
|
108
|
+
function removeOnDisk(input) {
|
|
109
|
+
return request("POST", "/remove", input);
|
|
110
|
+
}
|
|
111
|
+
function messageOf(error) {
|
|
112
|
+
return error instanceof Error ? error.message : String(error);
|
|
113
|
+
}
|
|
114
|
+
function errorText(error) {
|
|
115
|
+
const raw = messageOf(error);
|
|
116
|
+
if (/failed to fetch|networkerror|load failed|fetch failed/i.test(raw))
|
|
117
|
+
return "\u65E0\u6CD5\u8FDE\u63A5\u63D2\u4EF6\u670D\u52A1\uFF0C\u8BF7\u91CD\u542F\u5E94\u7528\u540E\u91CD\u8BD5";
|
|
118
|
+
if (/enoent|command not found/i.test(raw) && /git/i.test(raw))
|
|
119
|
+
return "\u672A\u627E\u5230 git \u547D\u4EE4\uFF0C\u8BF7\u5148\u5B89\u88C5 Git \u5E76\u52A0\u5165 PATH";
|
|
120
|
+
if (/already checked out at/i.test(raw))
|
|
121
|
+
return "\u8BE5\u5206\u652F\u5DF2\u88AB\u5176\u4ED6 worktree \u68C0\u51FA";
|
|
122
|
+
if (/not a git repository/i.test(raw))
|
|
123
|
+
return "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93";
|
|
124
|
+
if (/is a missing but already registered worktree/i.test(raw))
|
|
125
|
+
return "\u8BE5\u76EE\u5F55\u5DF2\u88AB git \u767B\u8BB0\u4E3A worktree\uFF0C\u8BF7\u5148\u6267\u884C git worktree prune";
|
|
126
|
+
if (/contains modified or untracked files/i.test(raw))
|
|
127
|
+
return "worktree \u5185\u6709\u672A\u63D0\u4EA4\u7684\u6539\u52A8\uFF0C\u65E0\u6CD5\u5220\u9664";
|
|
128
|
+
if (/is locked/i.test(raw))
|
|
129
|
+
return "worktree \u5DF2\u88AB\u9501\u5B9A\uFF0C\u65E0\u6CD5\u5220\u9664";
|
|
130
|
+
if (/invalid reference/i.test(raw))
|
|
131
|
+
return "\u5206\u652F\u6216\u63D0\u4EA4\u4E0D\u5B58\u5728";
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
function branchIssue(raw, branches, base, mode) {
|
|
135
|
+
const name2 = raw.trim();
|
|
136
|
+
if (name2 === "")
|
|
137
|
+
return "invalidEmpty";
|
|
138
|
+
if (mode === "new" && name2 === base)
|
|
139
|
+
return "invalidSame";
|
|
140
|
+
if (name2 === "@" || name2 === "HEAD")
|
|
141
|
+
return "invalidName";
|
|
142
|
+
if (name2.startsWith("-") || name2.endsWith("/") || name2.endsWith(".") || name2.endsWith(".lock"))
|
|
143
|
+
return "invalidName";
|
|
144
|
+
if (/[\s~^:?*[\\\u0000-\u001f]/.test(name2))
|
|
145
|
+
return "invalidName";
|
|
146
|
+
if (name2.includes("..") || name2.includes("@{") || name2.includes("//"))
|
|
147
|
+
return "invalidName";
|
|
148
|
+
const parts = name2.split("/");
|
|
149
|
+
if (parts.some((part) => part === "" || part.startsWith(".") || part.endsWith(".") || part.endsWith(".lock")))
|
|
150
|
+
return "invalidName";
|
|
151
|
+
if (mode === "new" && branches.some((branch) => branch.name === name2))
|
|
152
|
+
return "invalidExists";
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function flatten(name2) {
|
|
156
|
+
return name2.split("/").join("-");
|
|
157
|
+
}
|
|
158
|
+
var STYLE = `
|
|
159
|
+
.wt-dialog { width: min(460px, 100%) !important; }
|
|
160
|
+
.wt-dialog-body { display: flex; flex-direction: column; gap: 14px; width: 100%; min-width: 0; box-sizing: border-box; }
|
|
161
|
+
.wt-dialog-path { font-family: var(--dsh-font-mono, ui-monospace, monospace); overflow-wrap: anywhere; word-break: break-word; }
|
|
162
|
+
.wt-dialog-list { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; overflow-wrap: anywhere; }
|
|
163
|
+
`;
|
|
164
|
+
function ensureStyle() {
|
|
165
|
+
if (typeof document === "undefined" || document.querySelector("style[data-simple-worktree-style]") !== null)
|
|
166
|
+
return;
|
|
167
|
+
const style = document.createElement("style");
|
|
168
|
+
style.setAttribute("data-simple-worktree-style", "");
|
|
169
|
+
style.textContent = STYLE;
|
|
170
|
+
document.head.appendChild(style);
|
|
171
|
+
}
|
|
172
|
+
var fieldStyle = {
|
|
173
|
+
display: "flex",
|
|
174
|
+
flexDirection: "column",
|
|
175
|
+
gap: 6,
|
|
176
|
+
fontSize: 13,
|
|
177
|
+
lineHeight: "20px"
|
|
178
|
+
};
|
|
179
|
+
var pickerStyle = {
|
|
180
|
+
display: "flex",
|
|
181
|
+
alignItems: "center",
|
|
182
|
+
justifyContent: "space-between",
|
|
183
|
+
gap: 8,
|
|
184
|
+
width: "100%",
|
|
185
|
+
minWidth: 0,
|
|
186
|
+
height: 34,
|
|
187
|
+
padding: "0 10px",
|
|
188
|
+
boxSizing: "border-box",
|
|
189
|
+
borderRadius: 8,
|
|
190
|
+
border: "1px solid var(--dsw-alias-line-2, rgba(128,128,128,.35))",
|
|
191
|
+
background: "var(--dsw-alias-bg-2, rgba(128,128,128,.08))",
|
|
192
|
+
color: "inherit",
|
|
193
|
+
fontSize: 13,
|
|
194
|
+
cursor: "pointer",
|
|
195
|
+
textAlign: "left"
|
|
196
|
+
};
|
|
197
|
+
var dangerStyle = {
|
|
198
|
+
color: "var(--dsw-alias-text-error, #e5484d)",
|
|
199
|
+
fontSize: 12,
|
|
200
|
+
lineHeight: "18px"
|
|
201
|
+
};
|
|
202
|
+
var mutedStyle = {
|
|
203
|
+
opacity: 0.65,
|
|
204
|
+
fontSize: 12,
|
|
205
|
+
lineHeight: "18px"
|
|
206
|
+
};
|
|
207
|
+
function BranchPicker(props) {
|
|
208
|
+
const [open, setOpen] = (0, import_react.useState)(false);
|
|
209
|
+
const selected = props.branches.find((branch) => branch.name === props.value);
|
|
210
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
211
|
+
import_dsh_client_ui_primitives.Menu,
|
|
212
|
+
{
|
|
213
|
+
open,
|
|
214
|
+
autoFocus: true,
|
|
215
|
+
dense: true,
|
|
216
|
+
portal: true,
|
|
217
|
+
align: "start",
|
|
218
|
+
side: "bottom",
|
|
219
|
+
onClose: () => setOpen(false),
|
|
220
|
+
anchor: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
221
|
+
"button",
|
|
222
|
+
{
|
|
223
|
+
type: "button",
|
|
224
|
+
"data-modal-autofocus": props.autoFocus === true ? "" : void 0,
|
|
225
|
+
style: pickerStyle,
|
|
226
|
+
onClick: () => setOpen((next) => !next),
|
|
227
|
+
children: [
|
|
228
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selected === void 0 ? props.placeholder : selected.name }),
|
|
229
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 })
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
),
|
|
233
|
+
items: props.branches.map((branch) => ({
|
|
234
|
+
id: branch.name,
|
|
235
|
+
label: props.disableOccupied && branch.worktreePath !== null ? `${branch.name} \xB7 ${props.t("occupied")}` : branch.name,
|
|
236
|
+
disabled: props.disableOccupied && branch.worktreePath !== null
|
|
237
|
+
})),
|
|
238
|
+
onSelect: (id) => {
|
|
239
|
+
setOpen(false);
|
|
240
|
+
props.onSelect(id);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
function CreateWorktreeDialog(props) {
|
|
246
|
+
const { t } = props;
|
|
247
|
+
const [mode, setMode] = (0, import_react.useState)("new");
|
|
248
|
+
const [branch, setBranch] = (0, import_react.useState)("");
|
|
249
|
+
const [base, setBase] = (0, import_react.useState)(props.initial.branch ?? "HEAD");
|
|
250
|
+
const [existing, setExisting] = (0, import_react.useState)("");
|
|
251
|
+
const [info, setInfo] = (0, import_react.useState)(props.initial);
|
|
252
|
+
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
253
|
+
const [error, setError] = (0, import_react.useState)("");
|
|
254
|
+
(0, import_react.useEffect)(() => {
|
|
255
|
+
let alive = true;
|
|
256
|
+
probeRepo(props.cwd).then((next) => {
|
|
257
|
+
if (!alive || next.isRepo !== true)
|
|
258
|
+
return;
|
|
259
|
+
setInfo(next);
|
|
260
|
+
if (next.branch !== void 0)
|
|
261
|
+
setBase((current) => current === "HEAD" ? next.branch : current);
|
|
262
|
+
}).catch(() => {
|
|
263
|
+
});
|
|
264
|
+
return () => {
|
|
265
|
+
alive = false;
|
|
266
|
+
};
|
|
267
|
+
}, [props.cwd]);
|
|
268
|
+
const branches = info.branches ?? [];
|
|
269
|
+
const draft = mode === "new" ? branch : existing;
|
|
270
|
+
const issue = draft.trim() === "" ? null : branchIssue(draft, branches, base, mode);
|
|
271
|
+
const valid = draft.trim() !== "" && issue === null;
|
|
272
|
+
const separator = info.mainRoot?.includes("\\") === true ? "\\" : "/";
|
|
273
|
+
const target = info.mainRoot === void 0 || draft.trim() === "" ? "" : `${info.mainRoot}${separator}.agents${separator}worktree${separator}${flatten(draft.trim())}`;
|
|
274
|
+
const submit = async () => {
|
|
275
|
+
if (!valid || busy)
|
|
276
|
+
return;
|
|
277
|
+
setBusy(true);
|
|
278
|
+
setError("");
|
|
279
|
+
try {
|
|
280
|
+
await props.createWorktree({
|
|
281
|
+
path: props.cwd,
|
|
282
|
+
mode,
|
|
283
|
+
branch: draft.trim(),
|
|
284
|
+
...mode === "new" ? { base } : {}
|
|
285
|
+
});
|
|
286
|
+
props.onClose();
|
|
287
|
+
} catch (failure) {
|
|
288
|
+
setError(errorText(failure));
|
|
289
|
+
setBusy(false);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const close = () => {
|
|
293
|
+
if (!busy)
|
|
294
|
+
props.onClose();
|
|
295
|
+
};
|
|
296
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
297
|
+
import_dsh_client_ui_primitives.Modal,
|
|
298
|
+
{
|
|
299
|
+
open: true,
|
|
300
|
+
className: "wt-dialog",
|
|
301
|
+
title: t("createTitle"),
|
|
302
|
+
closeLabel: t("close"),
|
|
303
|
+
onClose: close,
|
|
304
|
+
footer: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
305
|
+
info.isLinkedWorktree === true && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
306
|
+
import_dsh_client_ui_primitives.Button,
|
|
307
|
+
{
|
|
308
|
+
variant: "ghost",
|
|
309
|
+
onClick: props.onDeleteCurrent,
|
|
310
|
+
disabled: busy,
|
|
311
|
+
style: { color: "var(--dsw-alias-text-error, #e5484d)", marginRight: "auto" },
|
|
312
|
+
children: t("deleteCurrent")
|
|
313
|
+
}
|
|
314
|
+
),
|
|
315
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "ghost", onClick: close, disabled: busy, children: t("cancel") }),
|
|
316
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "primary", onClick: () => {
|
|
317
|
+
void submit();
|
|
318
|
+
}, disabled: !valid || busy, children: busy ? t("creating") : t("create") })
|
|
319
|
+
] }),
|
|
320
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "wt-dialog-body", children: [
|
|
321
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
322
|
+
import_dsh_client_ui_primitives.Checkbox,
|
|
323
|
+
{
|
|
324
|
+
checked: mode === "new",
|
|
325
|
+
onChange: () => setMode("new"),
|
|
326
|
+
label: t("modeNew"),
|
|
327
|
+
disabled: busy
|
|
328
|
+
}
|
|
329
|
+
),
|
|
330
|
+
mode === "new" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
331
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: fieldStyle, children: [
|
|
332
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("branchName") }),
|
|
333
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
334
|
+
import_dsh_client_ui_primitives.Input,
|
|
335
|
+
{
|
|
336
|
+
value: branch,
|
|
337
|
+
placeholder: t("branchNamePlaceholder"),
|
|
338
|
+
spellCheck: false,
|
|
339
|
+
autoComplete: "off",
|
|
340
|
+
disabled: busy,
|
|
341
|
+
"data-modal-autofocus": "",
|
|
342
|
+
onChange: (event) => setBranch(event.target.value),
|
|
343
|
+
onKeyDown: (event) => {
|
|
344
|
+
if (event.key === "Enter")
|
|
345
|
+
void submit();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
] }),
|
|
350
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: fieldStyle, children: [
|
|
351
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("baseBranch") }),
|
|
352
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
353
|
+
BranchPicker,
|
|
354
|
+
{
|
|
355
|
+
value: base,
|
|
356
|
+
placeholder: t("chooseBranch"),
|
|
357
|
+
branches,
|
|
358
|
+
disableOccupied: false,
|
|
359
|
+
t,
|
|
360
|
+
onSelect: setBase
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
] })
|
|
364
|
+
] }),
|
|
365
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
366
|
+
import_dsh_client_ui_primitives.Checkbox,
|
|
367
|
+
{
|
|
368
|
+
checked: mode === "existing",
|
|
369
|
+
onChange: () => setMode("existing"),
|
|
370
|
+
label: t("modeExisting"),
|
|
371
|
+
disabled: busy
|
|
372
|
+
}
|
|
373
|
+
),
|
|
374
|
+
mode === "existing" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: fieldStyle, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
375
|
+
BranchPicker,
|
|
376
|
+
{
|
|
377
|
+
value: existing,
|
|
378
|
+
placeholder: t("chooseBranch"),
|
|
379
|
+
branches,
|
|
380
|
+
disableOccupied: true,
|
|
381
|
+
autoFocus: true,
|
|
382
|
+
t,
|
|
383
|
+
onSelect: setExisting
|
|
384
|
+
}
|
|
385
|
+
) }),
|
|
386
|
+
target !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: mutedStyle, children: [
|
|
387
|
+
t("target"),
|
|
388
|
+
":",
|
|
389
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "wt-dialog-path", style: { marginTop: 2 }, children: target })
|
|
390
|
+
] }),
|
|
391
|
+
issue !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: dangerStyle, children: t(issue) }),
|
|
392
|
+
error !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: dangerStyle, children: error })
|
|
393
|
+
] })
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
function WorktreeButton({ sessionId, useSessions, useWorkspaces, t, createWorktree }) {
|
|
398
|
+
const summary = useSessions((state) => state.byId[sessionId]);
|
|
399
|
+
const cwd = summary?.cwd;
|
|
400
|
+
const workspaceId = useWorkspaces((state) => state.items.find((item) => item.sessionIds.includes(sessionId))?.workspaceId);
|
|
401
|
+
const [repo, setRepo] = (0, import_react.useState)(null);
|
|
402
|
+
const [open, setOpen] = (0, import_react.useState)(false);
|
|
403
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
404
|
+
const busy = (0, import_react.useRef)(false);
|
|
405
|
+
(0, import_react.useEffect)(() => {
|
|
406
|
+
setOpen(false);
|
|
407
|
+
}, [cwd]);
|
|
408
|
+
const openDialog = async () => {
|
|
409
|
+
if (cwd === void 0 || busy.current)
|
|
410
|
+
return;
|
|
411
|
+
busy.current = true;
|
|
412
|
+
try {
|
|
413
|
+
const info = await probeRepo(cwd);
|
|
414
|
+
if (info.gitMissing === true) {
|
|
415
|
+
setError({ seq: Date.now(), text: t("gitMissing") });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (info.isRepo !== true) {
|
|
419
|
+
setError({ seq: Date.now(), text: t("notRepo") });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
setRepo(info);
|
|
423
|
+
setOpen(true);
|
|
424
|
+
} catch (failure) {
|
|
425
|
+
setError({ seq: Date.now(), text: errorText(failure) });
|
|
426
|
+
} finally {
|
|
427
|
+
busy.current = false;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
431
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
432
|
+
import_dsh_client_ui_primitives.Button,
|
|
433
|
+
{
|
|
434
|
+
variant: "ghost",
|
|
435
|
+
size: "sm",
|
|
436
|
+
title: t("buttonTitle"),
|
|
437
|
+
"aria-label": t("buttonTitle"),
|
|
438
|
+
icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconBranchOutlineRegular, { size: 16 }),
|
|
439
|
+
style: { width: 28, minWidth: 28, padding: 0, flex: "none" },
|
|
440
|
+
onClick: () => {
|
|
441
|
+
void openDialog();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
),
|
|
445
|
+
open && repo !== null && cwd !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
446
|
+
CreateWorktreeDialog,
|
|
447
|
+
{
|
|
448
|
+
cwd,
|
|
449
|
+
initial: repo,
|
|
450
|
+
t,
|
|
451
|
+
createWorktree,
|
|
452
|
+
onClose: () => setOpen(false),
|
|
453
|
+
onDeleteCurrent: () => {
|
|
454
|
+
setOpen(false);
|
|
455
|
+
openDeleteRequest(sessionId, summary?.displayTitle ?? "", workspaceId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
),
|
|
459
|
+
error !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Toast, { text: error.text, holdMs: 4e3, onDone: () => setError(null) }, error.seq)
|
|
460
|
+
] });
|
|
461
|
+
}
|
|
462
|
+
var deleteRequest = null;
|
|
463
|
+
var deleteSeq = 0;
|
|
464
|
+
var deleteListeners = /* @__PURE__ */ new Set();
|
|
465
|
+
function notifyDelete() {
|
|
466
|
+
for (const listener of deleteListeners)
|
|
467
|
+
listener();
|
|
468
|
+
}
|
|
469
|
+
function subscribeDelete(listener) {
|
|
470
|
+
deleteListeners.add(listener);
|
|
471
|
+
return () => {
|
|
472
|
+
deleteListeners.delete(listener);
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function getDeleteRequest() {
|
|
476
|
+
return deleteRequest;
|
|
477
|
+
}
|
|
478
|
+
function openDeleteRequest(sessionId, displayTitle, workspaceId) {
|
|
479
|
+
deleteSeq += 1;
|
|
480
|
+
deleteRequest = { sessionId, displayTitle, workspaceId, seq: deleteSeq };
|
|
481
|
+
notifyDelete();
|
|
482
|
+
}
|
|
483
|
+
function closeDeleteRequest() {
|
|
484
|
+
deleteRequest = null;
|
|
485
|
+
notifyDelete();
|
|
486
|
+
}
|
|
487
|
+
function DeleteWorktreeMenuItem({ sessionId, displayTitle, useMenuOpenState, useWorkspaces, t, requestDelete }) {
|
|
488
|
+
const workspaceId = useWorkspaces((state) => state.items.find((item) => item.sessionIds.includes(sessionId))?.workspaceId);
|
|
489
|
+
const [, setMenuOpen] = useMenuOpenState();
|
|
490
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
491
|
+
import_dsh_client_ui_primitives.MenuItemButton,
|
|
492
|
+
{
|
|
493
|
+
danger: true,
|
|
494
|
+
separatorBefore: true,
|
|
495
|
+
icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconTrashOutlineRegular, { size: 14 }),
|
|
496
|
+
onSelect: () => {
|
|
497
|
+
setMenuOpen(false);
|
|
498
|
+
requestDelete(sessionId, displayTitle, workspaceId);
|
|
499
|
+
},
|
|
500
|
+
children: t("deleteMenu")
|
|
501
|
+
}
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
function DeleteWorktreeOverlay({ useSessions, useWorkspaces, t, deleteWorktree }) {
|
|
505
|
+
const pending = (0, import_react.useSyncExternalStore)(subscribeDelete, getDeleteRequest);
|
|
506
|
+
const session = useSessions((state) => pending === null ? void 0 : state.byId[pending.sessionId]);
|
|
507
|
+
const workspace = useWorkspaces((state) => pending === null ? void 0 : state.items.find((item) => item.sessionIds.includes(pending.sessionId)));
|
|
508
|
+
const [toast, setToast] = (0, import_react.useState)(null);
|
|
509
|
+
const skip = (text) => {
|
|
510
|
+
closeDeleteRequest();
|
|
511
|
+
setToast({ seq: Date.now(), text });
|
|
512
|
+
};
|
|
513
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
514
|
+
pending !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
515
|
+
DeleteWorktreeGate,
|
|
516
|
+
{
|
|
517
|
+
pending,
|
|
518
|
+
cwd: session?.cwd,
|
|
519
|
+
workspaceId: pending.workspaceId ?? workspace?.workspaceId,
|
|
520
|
+
sessionIds: workspace?.sessionIds ?? [],
|
|
521
|
+
t,
|
|
522
|
+
deleteWorktree,
|
|
523
|
+
onClose: closeDeleteRequest,
|
|
524
|
+
onSkip: skip,
|
|
525
|
+
onDone: (text) => {
|
|
526
|
+
closeDeleteRequest();
|
|
527
|
+
setToast({ seq: Date.now(), text, tone: "success" });
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
pending.seq
|
|
531
|
+
),
|
|
532
|
+
toast !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Toast, { text: toast.text, tone: toast.tone, holdMs: 4e3, onDone: () => setToast(null) }, toast.seq)
|
|
533
|
+
] });
|
|
534
|
+
}
|
|
535
|
+
function DeleteWorktreeGate(props) {
|
|
536
|
+
const [ready, setReady] = (0, import_react.useState)(false);
|
|
537
|
+
(0, import_react.useEffect)(() => {
|
|
538
|
+
let alive = true;
|
|
539
|
+
if (props.cwd === void 0) {
|
|
540
|
+
props.onSkip(props.t("notWorktree"));
|
|
541
|
+
return () => {
|
|
542
|
+
alive = false;
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
probeRepo(props.cwd).then((info) => {
|
|
546
|
+
if (!alive)
|
|
547
|
+
return;
|
|
548
|
+
if (info.gitMissing === true) {
|
|
549
|
+
props.onSkip(props.t("gitMissing"));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (info.isRepo !== true || info.isLinkedWorktree !== true) {
|
|
553
|
+
props.onSkip(props.t("notWorktree"));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
setReady(true);
|
|
557
|
+
}).catch((failure) => {
|
|
558
|
+
if (alive)
|
|
559
|
+
props.onSkip(errorText(failure));
|
|
560
|
+
});
|
|
561
|
+
return () => {
|
|
562
|
+
alive = false;
|
|
563
|
+
};
|
|
564
|
+
}, [props.cwd]);
|
|
565
|
+
if (!ready)
|
|
566
|
+
return null;
|
|
567
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
568
|
+
DeleteWorktreeDialog,
|
|
569
|
+
{
|
|
570
|
+
pending: props.pending,
|
|
571
|
+
cwd: props.cwd,
|
|
572
|
+
workspaceId: props.workspaceId,
|
|
573
|
+
sessionIds: props.sessionIds,
|
|
574
|
+
t: props.t,
|
|
575
|
+
deleteWorktree: props.deleteWorktree,
|
|
576
|
+
onClose: props.onClose,
|
|
577
|
+
onDone: props.onDone
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
function DeleteWorktreeDialog(props) {
|
|
582
|
+
const { t } = props;
|
|
583
|
+
const [facts, setFacts] = (0, import_react.useState)(null);
|
|
584
|
+
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
585
|
+
const [error, setError] = (0, import_react.useState)("");
|
|
586
|
+
(0, import_react.useEffect)(() => {
|
|
587
|
+
let alive = true;
|
|
588
|
+
if (props.cwd === void 0)
|
|
589
|
+
return () => {
|
|
590
|
+
alive = false;
|
|
591
|
+
};
|
|
592
|
+
inspectOnDisk(props.cwd).then((next) => {
|
|
593
|
+
if (alive) setFacts(next);
|
|
594
|
+
}).catch((failure) => {
|
|
595
|
+
if (alive) setError(errorText(failure));
|
|
596
|
+
});
|
|
597
|
+
return () => {
|
|
598
|
+
alive = false;
|
|
599
|
+
};
|
|
600
|
+
}, [props.cwd]);
|
|
601
|
+
const confirm = async () => {
|
|
602
|
+
if (facts === null || props.workspaceId === void 0 || busy)
|
|
603
|
+
return;
|
|
604
|
+
setBusy(true);
|
|
605
|
+
setError("");
|
|
606
|
+
try {
|
|
607
|
+
await props.deleteWorktree({
|
|
608
|
+
sessionId: props.pending.sessionId,
|
|
609
|
+
workspaceId: props.workspaceId,
|
|
610
|
+
path: facts.path
|
|
611
|
+
});
|
|
612
|
+
props.onDone(t("deleted", { path: facts.path }));
|
|
613
|
+
} catch (failure) {
|
|
614
|
+
const message = messageOf(failure);
|
|
615
|
+
setError(message === "running" || message.includes("session-active") ? t("running") : message === "workspace-missing" ? t("workspaceMissing") : errorText(failure));
|
|
616
|
+
setBusy(false);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
const close = () => {
|
|
620
|
+
if (!busy)
|
|
621
|
+
props.onClose();
|
|
622
|
+
};
|
|
623
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
624
|
+
import_dsh_client_ui_primitives.Modal,
|
|
625
|
+
{
|
|
626
|
+
open: true,
|
|
627
|
+
className: "wt-dialog",
|
|
628
|
+
title: t("deleteTitle"),
|
|
629
|
+
closeLabel: t("close"),
|
|
630
|
+
onClose: close,
|
|
631
|
+
footer: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
632
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "ghost", onClick: close, disabled: busy, children: t("cancel") }),
|
|
633
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
634
|
+
import_dsh_client_ui_primitives.Button,
|
|
635
|
+
{
|
|
636
|
+
variant: "primary",
|
|
637
|
+
onClick: () => {
|
|
638
|
+
void confirm();
|
|
639
|
+
},
|
|
640
|
+
disabled: facts === null || props.workspaceId === void 0 || busy,
|
|
641
|
+
children: busy ? t("deleting") : t("confirmDelete")
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
] }),
|
|
645
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "wt-dialog-body", style: { fontSize: 13, lineHeight: "20px" }, children: [
|
|
646
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 6, fontWeight: 500, minWidth: 0 }, children: [
|
|
647
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.IconBranchOutlineRegular, { size: 14 }),
|
|
648
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: facts === null ? t("reading") : facts.branch !== "" ? facts.branch : t("detachedHead") })
|
|
649
|
+
] }),
|
|
650
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { ...dangerStyle, fontSize: 13 }, children: t("deleteWarning") }),
|
|
651
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("ul", { className: "wt-dialog-list", children: [
|
|
652
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: t("willArchive", { count: props.sessionIds.length }) }),
|
|
653
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: t("willRemoveWorkspace") }),
|
|
654
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { children: [
|
|
655
|
+
t("willRemoveDir"),
|
|
656
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "wt-dialog-path", style: { marginTop: 2 }, children: facts?.path ?? props.cwd ?? "" })
|
|
657
|
+
] }),
|
|
658
|
+
facts !== null && facts.dirty > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { style: dangerStyle, children: t("dirty", { count: facts.dirty }) }),
|
|
659
|
+
facts !== null && facts.ahead !== null && facts.ahead > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: t("ahead", { count: facts.ahead }) }),
|
|
660
|
+
facts !== null && facts.branch !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: t("branchKept", { branch: facts.branch }) })
|
|
661
|
+
] }),
|
|
662
|
+
error !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: dangerStyle, children: error })
|
|
663
|
+
] })
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
function fail(prefix, failure, path) {
|
|
668
|
+
return new Error(`${prefix}${errorText(failure)}\uFF08\u8DEF\u5F84\uFF1A${path}\uFF09`);
|
|
669
|
+
}
|
|
670
|
+
function apply(ctx) {
|
|
671
|
+
ensureStyle();
|
|
672
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "simple-worktree: dictionaries");
|
|
673
|
+
const createWorktree = async (input) => {
|
|
674
|
+
const created = await createOnDisk(input);
|
|
675
|
+
let workspace;
|
|
676
|
+
try {
|
|
677
|
+
workspace = await ctx.workspaces.create({ path: created.path });
|
|
678
|
+
} catch (failure) {
|
|
679
|
+
throw fail("worktree \u5DF2\u521B\u5EFA\uFF0C\u4F46\u6CE8\u518C\u5DE5\u4F5C\u533A\u5931\u8D25\uFF1A", failure, created.path);
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
ctx.uiWorkspace.startSession(workspace.workspaceId);
|
|
683
|
+
} catch (failure) {
|
|
684
|
+
throw fail("\u5DE5\u4F5C\u533A\u5DF2\u6CE8\u518C\uFF0C\u4F46\u65B0\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A", failure, created.path);
|
|
685
|
+
}
|
|
686
|
+
return created;
|
|
687
|
+
};
|
|
688
|
+
const deleteWorktree = async (input) => {
|
|
689
|
+
const workspaceId = input.workspaceId;
|
|
690
|
+
const workspace = ctx.workspaces.list.getSnapshot().items.find((item) => item.workspaceId === workspaceId);
|
|
691
|
+
if (workspace !== void 0) {
|
|
692
|
+
const sessions = ctx.sessions.list.getSnapshot().byId;
|
|
693
|
+
const running = workspace.sessionIds.filter((id) => sessions[id]?.running === true);
|
|
694
|
+
if (running.length > 0)
|
|
695
|
+
throw new Error("running");
|
|
696
|
+
for (const id of workspace.sessionIds)
|
|
697
|
+
await ctx.uiWorkspace.archiveSession(id);
|
|
698
|
+
await ctx.workspaces.delete(workspaceId);
|
|
699
|
+
}
|
|
700
|
+
await removeOnDisk({ path: input.path, force: true });
|
|
701
|
+
};
|
|
702
|
+
ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
703
|
+
name: "conversation.input.left",
|
|
704
|
+
id: "simple-worktree.create",
|
|
705
|
+
order: 6,
|
|
706
|
+
locale: NS,
|
|
707
|
+
inject: () => ({ createWorktree })
|
|
708
|
+
}, WorktreeButton));
|
|
709
|
+
ctx.slots.inject("sidebar.workspaces.session.menu.item", () => ctx.slots.register({
|
|
710
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
711
|
+
id: "simple-worktree.delete",
|
|
712
|
+
order: 500,
|
|
713
|
+
locale: NS,
|
|
714
|
+
inject: () => ({ requestDelete: openDeleteRequest })
|
|
715
|
+
}, DeleteWorktreeMenuItem));
|
|
716
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
717
|
+
name: "shell.overlay",
|
|
718
|
+
id: "simple-worktree.delete-dialog",
|
|
719
|
+
order: 0,
|
|
720
|
+
locale: NS,
|
|
721
|
+
inject: () => ({ deleteWorktree })
|
|
722
|
+
}, DeleteWorktreeOverlay));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return module.exports;
|
|
726
|
+
}
|
|
727
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// src/host/index.js
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
6
|
+
var name = "simple-worktree";
|
|
7
|
+
var inject = [];
|
|
8
|
+
var ROUTE_PREFIX = "/plugin/simple-worktree";
|
|
9
|
+
var BODY_LIMIT = 64 * 1024;
|
|
10
|
+
var MAX_SLOT_ATTEMPTS = 200;
|
|
11
|
+
function runGit(cwd, args) {
|
|
12
|
+
return new Promise((resolvePromise) => {
|
|
13
|
+
let child;
|
|
14
|
+
try {
|
|
15
|
+
child = spawn("git", args, { cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
resolvePromise({
|
|
18
|
+
ok: false,
|
|
19
|
+
code: -1,
|
|
20
|
+
missing: error?.code === "ENOENT",
|
|
21
|
+
stdout: "",
|
|
22
|
+
stderr: String(error?.message ?? error)
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let stdout = "";
|
|
27
|
+
let stderr = "";
|
|
28
|
+
child.stdout.setEncoding("utf8");
|
|
29
|
+
child.stderr.setEncoding("utf8");
|
|
30
|
+
child.stdout.on("data", (chunk) => {
|
|
31
|
+
stdout += chunk;
|
|
32
|
+
});
|
|
33
|
+
child.stderr.on("data", (chunk) => {
|
|
34
|
+
stderr += chunk;
|
|
35
|
+
});
|
|
36
|
+
child.on("error", (error) => {
|
|
37
|
+
resolvePromise({
|
|
38
|
+
ok: false,
|
|
39
|
+
code: -1,
|
|
40
|
+
missing: error?.code === "ENOENT",
|
|
41
|
+
stdout,
|
|
42
|
+
stderr: String(error?.message ?? error)
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
child.on("close", (code) => {
|
|
46
|
+
resolvePromise({ ok: code === 0, code: code ?? -1, stdout, stderr });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function samePath(left, right) {
|
|
51
|
+
const a = resolve(left);
|
|
52
|
+
const b = resolve(right);
|
|
53
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
54
|
+
}
|
|
55
|
+
function firstLine(text) {
|
|
56
|
+
return String(text ?? "").split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) ?? "";
|
|
57
|
+
}
|
|
58
|
+
function gitMessage(result, fallback) {
|
|
59
|
+
const line = firstLine(result.stderr);
|
|
60
|
+
return line.length > 0 ? line.replace(/^(fatal|error):\s*/i, "") : fallback;
|
|
61
|
+
}
|
|
62
|
+
function friendlyGitMessage(result, fallback) {
|
|
63
|
+
const raw = String(result?.stderr ?? "");
|
|
64
|
+
if (/already checked out at/i.test(raw))
|
|
65
|
+
return "\u8BE5\u5206\u652F\u5DF2\u88AB\u5176\u4ED6 worktree \u68C0\u51FA";
|
|
66
|
+
if (/is a missing but already registered worktree/i.test(raw))
|
|
67
|
+
return "\u8BE5\u76EE\u5F55\u5DF2\u88AB git \u767B\u8BB0\u4E3A worktree\uFF0C\u8BF7\u5148\u6267\u884C git worktree prune";
|
|
68
|
+
if (/contains modified or untracked files/i.test(raw))
|
|
69
|
+
return "worktree \u5185\u6709\u672A\u63D0\u4EA4\u7684\u6539\u52A8\uFF0C\u65E0\u6CD5\u5220\u9664";
|
|
70
|
+
if (/is locked/i.test(raw))
|
|
71
|
+
return "worktree \u5DF2\u88AB\u9501\u5B9A\uFF0C\u65E0\u6CD5\u5220\u9664";
|
|
72
|
+
if (/invalid reference/i.test(raw))
|
|
73
|
+
return "\u5206\u652F\u6216\u63D0\u4EA4\u4E0D\u5B58\u5728";
|
|
74
|
+
if (/not a git repository/i.test(raw))
|
|
75
|
+
return "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93";
|
|
76
|
+
if (/permission denied/i.test(raw))
|
|
77
|
+
return "\u6CA1\u6709\u6743\u9650\u8BBF\u95EE\u8BE5\u76EE\u5F55";
|
|
78
|
+
const line = firstLine(raw);
|
|
79
|
+
return line.length > 0 ? `${fallback}\uFF1A${line}` : fallback;
|
|
80
|
+
}
|
|
81
|
+
async function requireDirectory(value) {
|
|
82
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
83
|
+
throw new Error("\u7F3A\u5C11\u76EE\u5F55\u53C2\u6570");
|
|
84
|
+
if (!isAbsolute(value))
|
|
85
|
+
throw new Error(`\u76EE\u5F55\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${value}`);
|
|
86
|
+
let info;
|
|
87
|
+
try {
|
|
88
|
+
info = await stat(value);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${value}`);
|
|
91
|
+
}
|
|
92
|
+
if (!info.isDirectory())
|
|
93
|
+
throw new Error(`\u4E0D\u662F\u76EE\u5F55\uFF1A${value}`);
|
|
94
|
+
return resolve(value);
|
|
95
|
+
}
|
|
96
|
+
async function checkedOutBranches(top) {
|
|
97
|
+
const result = await runGit(top, ["worktree", "list", "--porcelain"]);
|
|
98
|
+
const map = /* @__PURE__ */ new Map();
|
|
99
|
+
if (!result.ok)
|
|
100
|
+
return map;
|
|
101
|
+
let current = "";
|
|
102
|
+
for (const raw of result.stdout.split(/\r?\n/)) {
|
|
103
|
+
if (raw.startsWith("worktree ")) {
|
|
104
|
+
current = raw.slice("worktree ".length);
|
|
105
|
+
} else if (raw.startsWith("branch refs/heads/")) {
|
|
106
|
+
map.set(raw.slice("branch refs/heads/".length), current);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return map;
|
|
110
|
+
}
|
|
111
|
+
async function localBranches(top) {
|
|
112
|
+
const result = await runGit(top, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
|
|
113
|
+
if (!result.ok)
|
|
114
|
+
return [];
|
|
115
|
+
return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
async function probe(directory) {
|
|
118
|
+
const dir = await requireDirectory(directory);
|
|
119
|
+
const inside = await runGit(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
120
|
+
if (!inside.ok) {
|
|
121
|
+
if (inside.missing === true)
|
|
122
|
+
return { isRepo: false, gitMissing: true, error: "\u672A\u627E\u5230 git \u547D\u4EE4\uFF0C\u8BF7\u5148\u5B89\u88C5 Git \u5E76\u52A0\u5165 PATH" };
|
|
123
|
+
return {
|
|
124
|
+
isRepo: false,
|
|
125
|
+
error: /not a git repository/i.test(String(inside.stderr)) ? "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93" : gitMessage(inside, "\u65E0\u6CD5\u8BFB\u53D6 Git \u4ED3\u5E93\u4FE1\u606F")
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const topResult = await runGit(dir, ["rev-parse", "--show-toplevel"]);
|
|
129
|
+
if (!topResult.ok)
|
|
130
|
+
return { isRepo: false, error: gitMessage(topResult, "\u65E0\u6CD5\u89E3\u6790\u4ED3\u5E93\u6839\u76EE\u5F55") };
|
|
131
|
+
const top = normalize(topResult.stdout.trim());
|
|
132
|
+
const gitDirResult = await runGit(dir, ["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
133
|
+
const commonDirResult = await runGit(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
134
|
+
let gitDir = gitDirResult.ok ? gitDirResult.stdout.trim() : "";
|
|
135
|
+
let commonDir = commonDirResult.ok ? commonDirResult.stdout.trim() : "";
|
|
136
|
+
if (gitDir === "" || commonDir === "") {
|
|
137
|
+
const legacyGit = await runGit(dir, ["rev-parse", "--git-dir"]);
|
|
138
|
+
const legacyCommon = await runGit(dir, ["rev-parse", "--git-common-dir"]);
|
|
139
|
+
gitDir = legacyGit.ok ? legacyGit.stdout.trim() : gitDir;
|
|
140
|
+
commonDir = legacyCommon.ok ? legacyCommon.stdout.trim() : commonDir;
|
|
141
|
+
}
|
|
142
|
+
if (gitDir !== "" && !isAbsolute(gitDir))
|
|
143
|
+
gitDir = resolve(top, gitDir);
|
|
144
|
+
if (commonDir !== "" && !isAbsolute(commonDir))
|
|
145
|
+
commonDir = resolve(top, commonDir);
|
|
146
|
+
const isLinkedWorktree = gitDir !== "" && commonDir !== "" && !samePath(gitDir, commonDir);
|
|
147
|
+
const mainRoot = normalize(isLinkedWorktree ? dirname(commonDir) : top);
|
|
148
|
+
const branchResult = await runGit(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
149
|
+
const branch = branchResult.ok ? branchResult.stdout.trim() : void 0;
|
|
150
|
+
const head = await runGit(dir, ["rev-parse", "--verify", "--quiet", "HEAD"]);
|
|
151
|
+
const outs = await checkedOutBranches(top);
|
|
152
|
+
const names = await localBranches(top);
|
|
153
|
+
const branches = names.map((branchName) => ({
|
|
154
|
+
name: branchName,
|
|
155
|
+
worktreePath: outs.get(branchName) ?? null
|
|
156
|
+
}));
|
|
157
|
+
return {
|
|
158
|
+
isRepo: true,
|
|
159
|
+
top,
|
|
160
|
+
mainRoot,
|
|
161
|
+
gitDir,
|
|
162
|
+
commonDir,
|
|
163
|
+
isLinkedWorktree,
|
|
164
|
+
branch,
|
|
165
|
+
hasCommits: head.ok,
|
|
166
|
+
branches
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function flattenBranch(branch) {
|
|
170
|
+
return branch.split("/").join("-");
|
|
171
|
+
}
|
|
172
|
+
function pickSlot(agentsDir, slug) {
|
|
173
|
+
for (let index = 1; index <= MAX_SLOT_ATTEMPTS; index += 1) {
|
|
174
|
+
const candidate = join(agentsDir, index === 1 ? slug : `${slug}-${index}`);
|
|
175
|
+
if (!existsSync(candidate))
|
|
176
|
+
return candidate;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`\u65E0\u6CD5\u4E3A ${slug} \u627E\u5230\u7A7A\u95F2\u76EE\u5F55\u540D`);
|
|
179
|
+
}
|
|
180
|
+
async function ensureGitignore(repoRoot) {
|
|
181
|
+
const file = join(repoRoot, ".gitignore");
|
|
182
|
+
let text = "";
|
|
183
|
+
try {
|
|
184
|
+
text = await readFile(file, "utf8");
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.code !== "ENOENT")
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
const covered = text.split(/\r?\n/).some((raw) => {
|
|
190
|
+
const line = raw.trim();
|
|
191
|
+
return line === ".agents/worktree" || line === ".agents/worktree/" || line === "/.agents/worktree" || line === "/.agents/worktree/" || line === ".agents" || line === ".agents/" || line === "/.agents" || line === "/.agents/";
|
|
192
|
+
});
|
|
193
|
+
if (covered)
|
|
194
|
+
return false;
|
|
195
|
+
const separator = text === "" || text.endsWith("\n") ? "" : "\n";
|
|
196
|
+
await writeFile(file, `${text}${separator}.agents/worktree/
|
|
197
|
+
`, "utf8");
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
async function assertBranchName(repoRoot, branch) {
|
|
201
|
+
const result = await runGit(repoRoot, ["check-ref-format", "--branch", branch]);
|
|
202
|
+
if (!result.ok)
|
|
203
|
+
throw new Error(`\u5206\u652F\u540D\u4E0D\u5408\u6CD5\uFF1A${branch}`);
|
|
204
|
+
}
|
|
205
|
+
async function createWorktree(body) {
|
|
206
|
+
const target = await probe(body?.path);
|
|
207
|
+
if (target.gitMissing === true)
|
|
208
|
+
throw new Error("\u672A\u627E\u5230 git \u547D\u4EE4\uFF0C\u8BF7\u5148\u5B89\u88C5 Git \u5E76\u52A0\u5165 PATH");
|
|
209
|
+
if (!target.isRepo)
|
|
210
|
+
throw new Error(target.error ?? "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93");
|
|
211
|
+
if (!target.hasCommits)
|
|
212
|
+
throw new Error("\u4ED3\u5E93\u8FD8\u6CA1\u6709\u4EFB\u4F55\u63D0\u4EA4\uFF0C\u65E0\u6CD5\u521B\u5EFA worktree");
|
|
213
|
+
const mode = body?.mode === "existing" ? "existing" : "new";
|
|
214
|
+
const branch = String(body?.branch ?? "").trim();
|
|
215
|
+
if (branch === "")
|
|
216
|
+
throw new Error("\u8BF7\u8F93\u5165\u5206\u652F\u540D");
|
|
217
|
+
await assertBranchName(target.mainRoot, branch);
|
|
218
|
+
const existing = target.branches.find((item) => item.name === branch);
|
|
219
|
+
if (mode === "new") {
|
|
220
|
+
if (existing !== void 0)
|
|
221
|
+
throw new Error(`\u672C\u5730\u5DF2\u5B58\u5728\u5206\u652F ${branch}`);
|
|
222
|
+
} else {
|
|
223
|
+
if (existing === void 0)
|
|
224
|
+
throw new Error(`\u672C\u5730\u4E0D\u5B58\u5728\u5206\u652F ${branch}`);
|
|
225
|
+
if (existing.worktreePath !== null && existing.worktreePath !== "")
|
|
226
|
+
throw new Error(`\u5206\u652F ${branch} \u5DF2\u88AB worktree \u5360\u7528\uFF1A${existing.worktreePath}`);
|
|
227
|
+
}
|
|
228
|
+
let base = "HEAD";
|
|
229
|
+
if (mode === "new") {
|
|
230
|
+
const requested = String(body?.base ?? "").trim();
|
|
231
|
+
base = requested === "" ? target.branch ?? "HEAD" : requested;
|
|
232
|
+
if (base !== "HEAD" && !target.branches.some((item) => item.name === base))
|
|
233
|
+
throw new Error(`\u8D77\u70B9\u5206\u652F\u4E0D\u5B58\u5728\uFF1A${base}`);
|
|
234
|
+
}
|
|
235
|
+
const agentsDir = join(target.mainRoot, ".agents", "worktree");
|
|
236
|
+
await mkdir(agentsDir, { recursive: true });
|
|
237
|
+
await ensureGitignore(target.mainRoot);
|
|
238
|
+
const directory = pickSlot(agentsDir, flattenBranch(branch));
|
|
239
|
+
await runGit(target.mainRoot, ["worktree", "prune"]);
|
|
240
|
+
const args = mode === "new" ? ["worktree", "add", "-b", branch, directory, base] : ["worktree", "add", directory, branch];
|
|
241
|
+
const result = await runGit(target.mainRoot, args);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
if (mode === "new") {
|
|
244
|
+
await runGit(target.mainRoot, ["branch", "-D", branch]);
|
|
245
|
+
}
|
|
246
|
+
await runGit(target.mainRoot, ["worktree", "prune"]);
|
|
247
|
+
throw new Error(friendlyGitMessage(result, "\u521B\u5EFA worktree \u5931\u8D25"));
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
path: directory,
|
|
251
|
+
branch,
|
|
252
|
+
repoRoot: target.mainRoot,
|
|
253
|
+
created: true
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async function inspectWorktree(body) {
|
|
257
|
+
const target = await probe(body?.path);
|
|
258
|
+
if (target.gitMissing === true)
|
|
259
|
+
throw new Error("\u672A\u627E\u5230 git \u547D\u4EE4\uFF0C\u8BF7\u5148\u5B89\u88C5 Git \u5E76\u52A0\u5165 PATH");
|
|
260
|
+
if (!target.isRepo)
|
|
261
|
+
throw new Error(target.error ?? "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93");
|
|
262
|
+
if (!target.isLinkedWorktree)
|
|
263
|
+
throw new Error("\u5F53\u524D\u4F1A\u8BDD\u4E0D\u5728 worktree \u4E2D");
|
|
264
|
+
const status = await runGit(target.top, ["status", "--porcelain"]);
|
|
265
|
+
const dirty = status.ok ? status.stdout.split(/\r?\n/).filter((line) => line.trim() !== "").length : 0;
|
|
266
|
+
const upstream = await runGit(target.top, ["rev-list", "--count", "@{upstream}..HEAD"]);
|
|
267
|
+
const ahead = upstream.ok ? Number.parseInt(upstream.stdout.trim(), 10) || 0 : null;
|
|
268
|
+
return {
|
|
269
|
+
path: target.top,
|
|
270
|
+
branch: target.branch ?? "",
|
|
271
|
+
dirty,
|
|
272
|
+
ahead,
|
|
273
|
+
repoRoot: target.mainRoot
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async function removeWorktree(body) {
|
|
277
|
+
const target = await probe(body?.path);
|
|
278
|
+
if (target.gitMissing === true)
|
|
279
|
+
throw new Error("\u672A\u627E\u5230 git \u547D\u4EE4\uFF0C\u8BF7\u5148\u5B89\u88C5 Git \u5E76\u52A0\u5165 PATH");
|
|
280
|
+
if (!target.isRepo)
|
|
281
|
+
throw new Error(target.error ?? "\u5F53\u524D\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93");
|
|
282
|
+
if (!target.isLinkedWorktree)
|
|
283
|
+
throw new Error("\u5F53\u524D\u4F1A\u8BDD\u4E0D\u5728 worktree \u4E2D");
|
|
284
|
+
if (samePath(target.top, target.mainRoot))
|
|
285
|
+
throw new Error("\u4E0D\u80FD\u5220\u9664\u4E3B\u4ED3\u5E93\u7684\u5DE5\u4F5C\u76EE\u5F55");
|
|
286
|
+
const force = body?.force === true;
|
|
287
|
+
const result = await runGit(target.mainRoot, [
|
|
288
|
+
"worktree",
|
|
289
|
+
"remove",
|
|
290
|
+
...force ? ["--force"] : [],
|
|
291
|
+
target.top
|
|
292
|
+
]);
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
await runGit(target.mainRoot, ["worktree", "unlock", target.top]);
|
|
295
|
+
await runGit(target.mainRoot, ["worktree", "prune"]);
|
|
296
|
+
if (existsSync(target.top))
|
|
297
|
+
await rm(target.top, { recursive: true, force: true });
|
|
298
|
+
await runGit(target.mainRoot, ["worktree", "prune"]);
|
|
299
|
+
if (existsSync(target.top))
|
|
300
|
+
throw new Error(friendlyGitMessage(result, "\u5220\u9664 worktree \u5931\u8D25"));
|
|
301
|
+
}
|
|
302
|
+
if (existsSync(target.top))
|
|
303
|
+
await rm(target.top, { recursive: true, force: true });
|
|
304
|
+
await runGit(target.mainRoot, ["worktree", "prune"]);
|
|
305
|
+
return {
|
|
306
|
+
removed: true,
|
|
307
|
+
path: target.top,
|
|
308
|
+
branch: target.branch ?? ""
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function readJson(req) {
|
|
312
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
313
|
+
const chunks = [];
|
|
314
|
+
let size = 0;
|
|
315
|
+
req.on("data", (chunk) => {
|
|
316
|
+
size += chunk.length;
|
|
317
|
+
if (size > BODY_LIMIT) {
|
|
318
|
+
req.destroy();
|
|
319
|
+
rejectPromise(new Error("\u8BF7\u6C42\u4F53\u8FC7\u5927"));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
chunks.push(chunk);
|
|
323
|
+
});
|
|
324
|
+
req.on("end", () => {
|
|
325
|
+
if (chunks.length === 0) {
|
|
326
|
+
resolvePromise({});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
resolvePromise(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
331
|
+
} catch (error) {
|
|
332
|
+
rejectPromise(new Error(`\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${error?.message ?? error}`));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
req.on("error", rejectPromise);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function send(res, status, body) {
|
|
339
|
+
res.writeHead(status, {
|
|
340
|
+
"content-type": "application/json; charset=utf-8",
|
|
341
|
+
"cache-control": "no-store"
|
|
342
|
+
});
|
|
343
|
+
res.end(JSON.stringify(body));
|
|
344
|
+
}
|
|
345
|
+
function apply(ctx) {
|
|
346
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
347
|
+
const get = (path, handler) => {
|
|
348
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
349
|
+
kind: "exact",
|
|
350
|
+
path,
|
|
351
|
+
handler: async (req, res) => {
|
|
352
|
+
try {
|
|
353
|
+
const query = new URL(req.url ?? "/", "http://x").searchParams;
|
|
354
|
+
send(res, 200, await handler(query));
|
|
355
|
+
} catch (error) {
|
|
356
|
+
send(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}), `simple-worktree: ${path} route`);
|
|
360
|
+
};
|
|
361
|
+
const post = (path, handler) => {
|
|
362
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
363
|
+
kind: "exact",
|
|
364
|
+
path,
|
|
365
|
+
handler: async (req, res) => {
|
|
366
|
+
try {
|
|
367
|
+
send(res, 200, await handler(await readJson(req)));
|
|
368
|
+
} catch (error) {
|
|
369
|
+
send(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}), `simple-worktree: ${path} route`);
|
|
373
|
+
};
|
|
374
|
+
get(`${ROUTE_PREFIX}/probe`, (query) => probe(query.get("path") ?? ""));
|
|
375
|
+
post(`${ROUTE_PREFIX}/create`, (body) => createWorktree(body));
|
|
376
|
+
post(`${ROUTE_PREFIX}/inspect`, (body) => inspectWorktree(body));
|
|
377
|
+
post(`${ROUTE_PREFIX}/remove`, (body) => removeWorktree(body));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
export {
|
|
381
|
+
ROUTE_PREFIX,
|
|
382
|
+
apply,
|
|
383
|
+
createWorktree,
|
|
384
|
+
inject,
|
|
385
|
+
inspectWorktree,
|
|
386
|
+
name,
|
|
387
|
+
probe,
|
|
388
|
+
removeWorktree
|
|
389
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltage12/dsh-simple-worktree",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH Web plugin: create and remove git worktrees from the conversation UI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "xiejinyang (https://github.com/voltage12)",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/voltage12/dsh-simple-worktree.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/voltage12/dsh-simple-worktree#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/voltage12/dsh-simple-worktree/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"dsh",
|
|
17
|
+
"dsh-plugin",
|
|
18
|
+
"worktree",
|
|
19
|
+
"git",
|
|
20
|
+
"deepseek-harness"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "lib/index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./lib/index.js",
|
|
26
|
+
"./client": "./lib/client.js",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md",
|
|
33
|
+
"docs"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
40
|
+
"dsh": ">=0.2.1-rc.2 <0.3.0"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"client": {
|
|
47
|
+
"platform": "web",
|
|
48
|
+
"inject": [
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-workspace",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "node build.mjs",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
59
|
+
"prepack": "node build.mjs && tsc -p tsconfig.json --noEmit"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
63
|
+
"@deepseek-ai/dsh-api-remote-access": "0.2.1-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-api-remotes": "0.2.1-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-client-store": "0.2.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-session": "0.2.1-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.2",
|
|
77
|
+
"@types/react": "^18.3.31",
|
|
78
|
+
"@types/react-dom": "^18.3.7",
|
|
79
|
+
"esbuild": "0.25.10",
|
|
80
|
+
"playwright-core": "1.55.1",
|
|
81
|
+
"react": "^18.3.1",
|
|
82
|
+
"react-dom": "^18.3.1",
|
|
83
|
+
"typescript": "5.9.3"
|
|
84
|
+
}
|
|
85
|
+
}
|