@qzsy/dsh-workspace-combobox 0.1.0 → 0.2.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/README.md +69 -50
- package/dist/client.js +407 -3
- package/dist/index.js +220 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,76 +1,95 @@
|
|
|
1
1
|
# @qzsy/dsh-workspace-combobox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Workspace combobox plus a Git branch picker for DeepSeek Harness.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
~~~bash
|
|
8
|
-
dsh plugin add @qzsy/dsh-workspace-combobox
|
|
8
|
+
dsh plugin add @qzsy/dsh-workspace-combobox@0.2.0
|
|
9
9
|
~~~
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Features
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
### Workspace combobox
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
The dropdown above the New Session composer becomes a focused search field with
|
|
16
|
+
a keyboard-driven list. One mixed-script query matches workspace names and full
|
|
17
|
+
paths:
|
|
17
18
|
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Matched characters are highlighted; arrows move, Enter picks, Escape closes.
|
|
24
|
-
- **`Ctrl+.` switches workspace from anywhere.** A frame-wide switcher opens
|
|
25
|
-
over the current surface — including sessions where the New Session picker is
|
|
26
|
-
not rendered — with the same search, in the roomier dialog layout. Enter
|
|
27
|
-
**starts a new conversation in that Workspace** (`uiWorkspace.startSession`)
|
|
28
|
-
instead of reopening the Workspace's latest session. The binding is a normal
|
|
29
|
-
editable command (`workspace.combobox`) in Settings → Keyboard shortcuts.
|
|
19
|
+
- Han characters: 群星
|
|
20
|
+
- full pinyin: qunxing
|
|
21
|
+
- initials: qx
|
|
22
|
+
- English subsequences: flut
|
|
23
|
+
- mixed forms: qunxing-erp, 群erp
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
Arrow keys move, Enter picks, Escape closes. The panel renders through a React
|
|
26
|
+
portal, like the shipped picker.
|
|
27
|
+
|
|
28
|
+
<code>Ctrl+.</code> (macOS: <code>⌃.</code>) opens a frame-wide workspace
|
|
29
|
+
switcher over any surface. The command is editable in Settings -> Keyboard
|
|
30
|
+
shortcuts.
|
|
31
|
+
|
|
32
|
+
### Git branch picker
|
|
33
|
+
|
|
34
|
+
When the selected workspace is a Git repository, a branch chip appears in the
|
|
35
|
+
New Session hero row, to the right of the agent preset (标准模式):
|
|
36
|
+
|
|
37
|
+
- shows the current branch, or detached HEAD with the short commit
|
|
38
|
+
- local and remote tabs, each with a count and search
|
|
39
|
+
- current branch is marked
|
|
40
|
+
- selecting a remote branch checks it out as a tracking branch
|
|
41
|
+
- switching shows a spinner in the chip and an animated overlay in the panel;
|
|
42
|
+
all branch rows are disabled until the operation finishes
|
|
43
|
+
- switching never forces, resets, or discards local changes; Git's own error is
|
|
44
|
+
shown in the panel when the working tree is dirty
|
|
45
|
+
- a second switch for the same workspace is rejected while one is running
|
|
34
46
|
|
|
35
47
|
## Where it plugs in
|
|
36
48
|
|
|
37
49
|
| Surface | Mechanism |
|
|
38
50
|
| --- | --- |
|
|
39
|
-
| New Session workspace dropdown |
|
|
40
|
-
|
|
|
41
|
-
|
|
|
51
|
+
| New Session workspace dropdown | <code>conversation.hero.workspace</code> occupant at <code>priority: -1</code>; the slot registry renders the lowest priority, shadowing the shipped picker panel. The trigger chip, open state, <code>selectedId</code>, and <code>onPick</code> still come from the conversation owner. |
|
|
52
|
+
| Branch chip beside the agent preset | <code>conversation.hero.options</code> list entry <code>workspace-combobox.branch</code>, after the agent-preset seat. The selected workspace path is shared through a small client store written by the workspace picker. |
|
|
53
|
+
| Frame-wide switcher | <code>shell.overlay</code> entry driven by the <code>workspace.combobox</code> shortcut. |
|
|
54
|
+
| Host capability | Two authenticated <code>/api</code> routes registered on <code>ctx.connection.fetch</code>: <code>/api/workspace-git</code> and <code>/api/workspace-git/switch</code>. |
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`uiWorkspace.pickDirectory()`, registers the path through `workspaces.create()`,
|
|
46
|
-
and then picks the new workspace.
|
|
56
|
+
The browser cannot read Git state by itself, so the Host half runs <code>git</code>
|
|
57
|
+
through <code>execFile</code> (no shell):
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
- paths must be absolute directories
|
|
60
|
+
- when the Workspace registry is mounted, the path must be one the registry owns
|
|
61
|
+
- switch targets must be present in the latest local or remote listing
|
|
62
|
+
- branch names starting with <code>-</code> or containing whitespace are rejected
|
|
63
|
+
- read timeout is 10s, switch timeout is 60s
|
|
64
|
+
- <code>GIT_TERMINAL_PROMPT=0</code> prevents credential prompts from hanging the plugin
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
vendored engine already inlined.
|
|
66
|
+
Remote branches are the **local remote-tracking refs** (<code>refs/remotes</code>).
|
|
67
|
+
The plugin does not run <code>git fetch</code>, so the list reflects the last fetch
|
|
68
|
+
and switching is fast and offline-safe.
|
|
54
69
|
|
|
55
70
|
## Files
|
|
56
71
|
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
vendored
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
- <code>package.json</code> - bundle manifest and <code>dshBuild</code> configuration
|
|
73
|
+
- <code>cordis.patch.yml</code> - inserts the Host row
|
|
74
|
+
- <code>src/index.ts</code> - Host half: Git status/switch routes and path validation
|
|
75
|
+
- <code>runtime/client.js</code> - prebuilt DSH Client module: combobox, branch
|
|
76
|
+
picker, switcher, shortcut, and the vendored pinyin engine
|
|
77
|
+
- <code>locale/en.json</code>, <code>locale/zh.json</code> - Plugin Manager title and description
|
|
78
|
+
- <code>icon.svg</code> - original artwork
|
|
79
|
+
- <code>vendor/</code> - vendored text-search-engine build and license
|
|
80
|
+
|
|
81
|
+
## Upstream
|
|
82
|
+
|
|
83
|
+
The workspace combobox is derived from the local DSH plugin
|
|
84
|
+
<code>dsh-workspace-combobox</code>; the original workspace search and switcher
|
|
85
|
+
remain MIT licensed. The Git branch picker and Host routes are community
|
|
86
|
+
additions.
|
|
67
87
|
|
|
68
88
|
## Limits
|
|
69
89
|
|
|
70
|
-
- The trigger chip
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
-
|
|
76
|
-
shipped picker immediately.
|
|
90
|
+
- The workspace trigger chip belongs to the conversation owner and is not
|
|
91
|
+
replaceable; typing happens inside the opened panel.
|
|
92
|
+
- Remote branch lists use already-fetched remote-tracking refs, not a live
|
|
93
|
+
network fetch.
|
|
94
|
+
- The branch chip lives in the conversation hero row, so it is visible while
|
|
95
|
+
the new-session hero is shown.
|
package/dist/client.js
CHANGED
|
@@ -50,7 +50,20 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
'footer.move': '选择',
|
|
51
51
|
'footer.open': '打开',
|
|
52
52
|
'footer.close': '关闭',
|
|
53
|
-
'footer.switch': '全局切换'
|
|
53
|
+
'footer.switch': '全局切换',
|
|
54
|
+
'branch.title': '切换 Git 分支',
|
|
55
|
+
'branch.loading': '读取分支…',
|
|
56
|
+
'branch.switching': '正在切换…',
|
|
57
|
+
'branch.local': '本地',
|
|
58
|
+
'branch.remote': '远程',
|
|
59
|
+
'branch.search': '搜索分支',
|
|
60
|
+
'branch.empty': '没有匹配的分支',
|
|
61
|
+
'branch.current': '当前',
|
|
62
|
+
'branch.detached': '游离 HEAD',
|
|
63
|
+
'branch.remoteHint': '远程分支会创建同名跟踪分支',
|
|
64
|
+
'branch.failed': '切换失败',
|
|
65
|
+
'branch.refreshFailed': '刷新分支失败,显示的是上一次结果',
|
|
66
|
+
'branch.close': '关闭'
|
|
54
67
|
};
|
|
55
68
|
const en = {
|
|
56
69
|
'command.open': 'Switch workspace',
|
|
@@ -65,7 +78,20 @@ window.__ModuleLoader__.load({
|
|
|
65
78
|
'footer.move': 'select',
|
|
66
79
|
'footer.open': 'open',
|
|
67
80
|
'footer.close': 'close',
|
|
68
|
-
'footer.switch': 'anywhere'
|
|
81
|
+
'footer.switch': 'anywhere',
|
|
82
|
+
'branch.title': 'Switch Git branch',
|
|
83
|
+
'branch.loading': 'Reading branches…',
|
|
84
|
+
'branch.switching': 'Switching…',
|
|
85
|
+
'branch.local': 'Local',
|
|
86
|
+
'branch.remote': 'Remote',
|
|
87
|
+
'branch.search': 'Search branches',
|
|
88
|
+
'branch.empty': 'No matching branches',
|
|
89
|
+
'branch.current': 'current',
|
|
90
|
+
'branch.detached': 'Detached HEAD',
|
|
91
|
+
'branch.remoteHint': 'Remote branches are checked out as tracking branches',
|
|
92
|
+
'branch.failed': 'Could not switch branch',
|
|
93
|
+
'branch.refreshFailed': 'Could not refresh; showing the previous result',
|
|
94
|
+
'branch.close': 'Close'
|
|
69
95
|
};
|
|
70
96
|
//#endregion
|
|
71
97
|
|
|
@@ -112,6 +138,45 @@ window.__ModuleLoader__.load({
|
|
|
112
138
|
.wsc-hint{display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
|
|
113
139
|
.wsc-hintRight{margin-left:auto}
|
|
114
140
|
.wsc-key{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border:0.5px solid var(--dsw-alias-border-l2);border-radius:4px;color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px}
|
|
141
|
+
.wsc-branch-chip{display:inline-flex;align-items:center;gap:6px;height:28px;max-width:min(260px,36vw);padding:0 8px;border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer;transition:background .12s,border-color .12s,color .12s}
|
|
142
|
+
.wsc-branch-chip:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
|
|
143
|
+
.wsc-branch-chip[data-open=true]{background:var(--dsw-alias-interactive-bg-active);border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
|
|
144
|
+
.wsc-branch-chip:disabled{cursor:default;opacity:.8}
|
|
145
|
+
.wsc-branch-chipIcon{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-tertiary)}
|
|
146
|
+
.wsc-branch-name{min-width:0;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
147
|
+
.wsc-branch-caret{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-caption)}
|
|
148
|
+
.wsc-branch-menu{position:fixed;z-index:1203;display:flex;flex-direction:column;min-height:0;max-height:min(420px,72vh);overflow:hidden;border:0.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);font-family:var(--dsw-font-family,inherit);font-size:12px;animation:wsc-fade .08s var(--ds-ease-out,ease-out)}
|
|
149
|
+
.wsc-branch-head{display:flex;align-items:center;gap:9px;padding:12px 12px 10px;border-bottom:0.5px solid var(--dsw-alias-border-l1)}
|
|
150
|
+
.wsc-branch-headIcon{flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-secondary)}
|
|
151
|
+
.wsc-branch-headMain{flex:1;min-width:0}.wsc-branch-headTitle{font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary)}
|
|
152
|
+
.wsc-branch-headName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:19px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
153
|
+
.wsc-branch-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
154
|
+
.wsc-branch-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
155
|
+
.wsc-branch-tabs{display:flex;align-items:center;gap:4px;padding:8px 10px 0}
|
|
156
|
+
.wsc-branch-tab{display:inline-flex;align-items:center;gap:5px;padding:4px 8px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:17px;cursor:pointer}
|
|
157
|
+
.wsc-branch-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
158
|
+
.wsc-branch-tab[data-active=true]{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}
|
|
159
|
+
.wsc-branch-count{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border-radius:5px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}
|
|
160
|
+
.wsc-branch-searchRow{padding:8px 10px 6px}
|
|
161
|
+
.wsc-branch-search{box-sizing:border-box;width:100%;height:30px;padding:0 9px;border:0.5px solid var(--dsw-alias-border-l2);border-radius:7px;outline:none;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px}
|
|
162
|
+
.wsc-branch-search:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
163
|
+
.wsc-branch-search::placeholder{color:var(--dsw-alias-label-caption)}
|
|
164
|
+
.wsc-branch-list{flex:1;min-height:80px;overflow-y:auto;overscroll-behavior:contain;padding:2px 6px 6px}
|
|
165
|
+
.wsc-branch-row{display:flex;align-items:center;gap:8px;width:100%;padding:7px 8px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:17px;text-align:left;cursor:pointer}
|
|
166
|
+
.wsc-branch-row:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
167
|
+
.wsc-branch-row[data-current=true]{background:var(--dsw-alias-interactive-bg-active)}
|
|
168
|
+
.wsc-branch-row:disabled{opacity:.55;cursor:default}
|
|
169
|
+
.wsc-branch-rowIcon{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-tertiary)}
|
|
170
|
+
.wsc-branch-rowName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
171
|
+
.wsc-branch-badge{flex:none;padding:1px 5px;border-radius:4px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px}
|
|
172
|
+
.wsc-branch-empty{padding:18px 12px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
173
|
+
.wsc-branch-error{padding:2px 10px 6px;color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;overflow-wrap:anywhere}
|
|
174
|
+
.wsc-branch-note{padding:6px 10px 9px;border-top:0.5px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-caption);font-size:10px;line-height:15px}
|
|
175
|
+
.wsc-branch-loading{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:18px;text-align:center;background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 88%,transparent);backdrop-filter:blur(2px)}
|
|
176
|
+
.wsc-branch-loadingText{font-size:12px;line-height:18px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
177
|
+
.wsc-branch-loadingDetail{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-family-mono,var(--dsw-font-family,inherit));font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
|
|
178
|
+
.wsc-spinner{display:inline-block;flex:none;width:14px;height:14px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-tertiary) 25%,transparent);border-top-color:var(--dsw-alias-state-business-primary);border-radius:50%;animation:wsc-spin .8s linear infinite}
|
|
179
|
+
@keyframes wsc-spin{to{transform:rotate(360deg)}}
|
|
115
180
|
@keyframes wsc-fade{from{opacity:0}to{opacity:1}}
|
|
116
181
|
`;
|
|
117
182
|
//#endregion
|
|
@@ -142,6 +207,34 @@ window.__ModuleLoader__.load({
|
|
|
142
207
|
}, [store]);
|
|
143
208
|
return value;
|
|
144
209
|
}
|
|
210
|
+
function createPathStore() {
|
|
211
|
+
let path = '';
|
|
212
|
+
const listeners = new Set();
|
|
213
|
+
return {
|
|
214
|
+
get: () => path,
|
|
215
|
+
set: (next) => {
|
|
216
|
+
const value = typeof next === 'string' ? next : '';
|
|
217
|
+
if (value === path) return;
|
|
218
|
+
path = value;
|
|
219
|
+
for (const listener of [...listeners]) listener();
|
|
220
|
+
},
|
|
221
|
+
subscribe: (listener) => {
|
|
222
|
+
listeners.add(listener);
|
|
223
|
+
return () => {
|
|
224
|
+
listeners.delete(listener);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function usePathValue(store) {
|
|
230
|
+
const [value, setValue] = useState(() => (store !== undefined && typeof store.get === 'function' ? store.get() : ''));
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (store === undefined || typeof store.subscribe !== 'function') return undefined;
|
|
233
|
+
setValue(typeof store.get === 'function' ? store.get() : '');
|
|
234
|
+
return store.subscribe(() => setValue(typeof store.get === 'function' ? store.get() : ''));
|
|
235
|
+
}, [store]);
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
145
238
|
//#endregion
|
|
146
239
|
|
|
147
240
|
//#region data
|
|
@@ -340,6 +433,24 @@ window.__ModuleLoader__.load({
|
|
|
340
433
|
h('path', { d: 'M8 3.4v9.2M3.4 8h9.2', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' })
|
|
341
434
|
);
|
|
342
435
|
}
|
|
436
|
+
function GitBranchIcon({ className }) {
|
|
437
|
+
return h('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', className, 'aria-hidden': true },
|
|
438
|
+
h('circle', { cx: 4.4, cy: 3.1, r: 1.5, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
439
|
+
h('circle', { cx: 4.4, cy: 12.9, r: 1.5, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
440
|
+
h('circle', { cx: 11.6, cy: 6.1, r: 1.5, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
441
|
+
h('path', { d: 'M4.4 4.6v6.8M5.9 3.1h2.5a3.1 3.1 0 0 1 3.1 3v0', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' })
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
function ChevronDownIcon({ className }) {
|
|
445
|
+
return h('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', className, 'aria-hidden': true },
|
|
446
|
+
h('path', { d: 'm4 6 4 4 4-4', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' })
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
function CloseIcon() {
|
|
450
|
+
return h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
|
|
451
|
+
h('path', { d: 'm4 4 8 8m0-8-8 8', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' })
|
|
452
|
+
);
|
|
453
|
+
}
|
|
343
454
|
//#endregion
|
|
344
455
|
|
|
345
456
|
//#region rows
|
|
@@ -435,7 +546,7 @@ window.__ModuleLoader__.load({
|
|
|
435
546
|
* trigger chip and hands over open/close state, the anchor, and the
|
|
436
547
|
* selection; this panel adds pinyin-aware search over names and paths.
|
|
437
548
|
*/
|
|
438
|
-
function HeroWorkspaceCombobox({ open, anchorRef, selectedId, onPick, onClose, actions, getHome, shortcutLabel, useWorkspaces, useSessions, t }) {
|
|
549
|
+
function HeroWorkspaceCombobox({ open, anchorRef, selectedId, onPick, onClose, actions, getHome, shortcutLabel, selection, useWorkspaces, useSessions, t }) {
|
|
439
550
|
const home = typeof getHome === 'function' ? getHome() : '';
|
|
440
551
|
const rows = useWorkspaceRows(useWorkspaces, useSessions, home, false);
|
|
441
552
|
const [query, setQuery] = useState('');
|
|
@@ -446,6 +557,11 @@ window.__ModuleLoader__.load({
|
|
|
446
557
|
const inputRef = useRef(null);
|
|
447
558
|
const panelRef = useRef(null);
|
|
448
559
|
const items = useWorkspaceSearch(rows, query);
|
|
560
|
+
const selectedRow = selectedId === undefined || selectedId === null ? undefined : rows.find((row) => String(row.id) === String(selectedId));
|
|
561
|
+
const selectedPath = selectedRow === undefined ? '' : String(selectedRow.path ?? '');
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
if (selection !== undefined && typeof selection.set === 'function') selection.set(selectedPath);
|
|
564
|
+
}, [selection, selectedPath]);
|
|
449
565
|
|
|
450
566
|
useEffect(() => {
|
|
451
567
|
if (!open) return undefined;
|
|
@@ -585,6 +701,278 @@ window.__ModuleLoader__.load({
|
|
|
585
701
|
if (createPortal !== null && typeof document !== 'undefined') return createPortal(panel, document.body);
|
|
586
702
|
return panel;
|
|
587
703
|
}
|
|
704
|
+
|
|
705
|
+
//#region git branch
|
|
706
|
+
const BRANCH_STATUS_ENDPOINT = 'api/workspace-git';
|
|
707
|
+
const BRANCH_SWITCH_ENDPOINT = 'api/workspace-git/switch';
|
|
708
|
+
|
|
709
|
+
function branchEndpoint(route) {
|
|
710
|
+
if (typeof document !== 'undefined' && typeof document.baseURI === 'string') {
|
|
711
|
+
try {
|
|
712
|
+
return new URL(route, document.baseURI).href;
|
|
713
|
+
} catch (error) {
|
|
714
|
+
/* fall through to a relative request */
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return route;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function branchRequest(route, payload, signal) {
|
|
721
|
+
const response = await fetch(branchEndpoint(route), {
|
|
722
|
+
method: 'POST',
|
|
723
|
+
headers: { 'content-type': 'application/json' },
|
|
724
|
+
body: JSON.stringify(payload),
|
|
725
|
+
credentials: 'same-origin',
|
|
726
|
+
signal
|
|
727
|
+
});
|
|
728
|
+
let body = null;
|
|
729
|
+
try {
|
|
730
|
+
body = await response.json();
|
|
731
|
+
} catch (error) {
|
|
732
|
+
/* the error below carries the HTTP failure */
|
|
733
|
+
}
|
|
734
|
+
if (response.ok !== true) {
|
|
735
|
+
const message = body !== null && body !== undefined && body.error !== null && body.error !== undefined ? body.error.message : undefined;
|
|
736
|
+
throw new Error(typeof message === 'string' && message.length > 0 ? message : 'HTTP ' + response.status);
|
|
737
|
+
}
|
|
738
|
+
if (body === null || typeof body !== 'object' || body.ok !== true) {
|
|
739
|
+
const message = body !== null && body !== undefined && body.error !== null && body.error !== undefined ? body.error.message : undefined;
|
|
740
|
+
throw new Error(typeof message === 'string' && message.length > 0 ? message : 'git branch request failed');
|
|
741
|
+
}
|
|
742
|
+
return body.value === null || typeof body.value !== 'object' ? {} : body.value;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function BranchSelector({ selection, t }) {
|
|
746
|
+
const path = usePathValue(selection);
|
|
747
|
+
const [status, setStatus] = useState(null);
|
|
748
|
+
const [loading, setLoading] = useState(false);
|
|
749
|
+
const [open, setOpen] = useState(false);
|
|
750
|
+
const [tab, setTab] = useState('local');
|
|
751
|
+
const [query, setQuery] = useState('');
|
|
752
|
+
const [switching, setSwitching] = useState(null);
|
|
753
|
+
const [error, setError] = useState('');
|
|
754
|
+
const [position, setPosition] = useState(null);
|
|
755
|
+
const buttonRef = useRef(null);
|
|
756
|
+
const panelRef = useRef(null);
|
|
757
|
+
const requestSeq = useRef(0);
|
|
758
|
+
|
|
759
|
+
const load = async (targetPath, options) => {
|
|
760
|
+
const seq = requestSeq.current + 1;
|
|
761
|
+
requestSeq.current = seq;
|
|
762
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0) {
|
|
763
|
+
setStatus(null);
|
|
764
|
+
setLoading(false);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
setLoading(true);
|
|
768
|
+
try {
|
|
769
|
+
const value = await branchRequest(BRANCH_STATUS_ENDPOINT, { path: targetPath });
|
|
770
|
+
if (seq !== requestSeq.current) return;
|
|
771
|
+
setStatus(value);
|
|
772
|
+
} catch (failure) {
|
|
773
|
+
if (seq !== requestSeq.current) return;
|
|
774
|
+
if (options !== undefined && options.quiet === true) {
|
|
775
|
+
setError(t('branch.refreshFailed'));
|
|
776
|
+
} else {
|
|
777
|
+
setStatus(null);
|
|
778
|
+
}
|
|
779
|
+
console.warn('workspace combobox: branch status failed', failure);
|
|
780
|
+
} finally {
|
|
781
|
+
if (seq === requestSeq.current) setLoading(false);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
useEffect(() => {
|
|
786
|
+
requestSeq.current += 1;
|
|
787
|
+
setOpen(false);
|
|
788
|
+
setQuery('');
|
|
789
|
+
setError('');
|
|
790
|
+
setSwitching(null);
|
|
791
|
+
setPosition(null);
|
|
792
|
+
if (path.length === 0) {
|
|
793
|
+
setStatus(null);
|
|
794
|
+
setLoading(false);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
setStatus(null);
|
|
798
|
+
load(path);
|
|
799
|
+
}, [path]);
|
|
800
|
+
|
|
801
|
+
useLayoutEffect(() => {
|
|
802
|
+
if (!open) {
|
|
803
|
+
setPosition(null);
|
|
804
|
+
return undefined;
|
|
805
|
+
}
|
|
806
|
+
const update = () => {
|
|
807
|
+
const button = buttonRef.current;
|
|
808
|
+
if (button === null) return;
|
|
809
|
+
const rect = button.getBoundingClientRect();
|
|
810
|
+
const width = Math.max(280, Math.min(360, window.innerWidth - 16));
|
|
811
|
+
const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
|
|
812
|
+
const estimated = Math.min(420, window.innerHeight * 0.72);
|
|
813
|
+
const below = rect.bottom + 6;
|
|
814
|
+
const top = below + estimated > window.innerHeight ? Math.max(8, rect.top - estimated - 6) : below;
|
|
815
|
+
setPosition({ left, top, width });
|
|
816
|
+
};
|
|
817
|
+
update();
|
|
818
|
+
window.addEventListener('resize', update);
|
|
819
|
+
window.addEventListener('scroll', update, true);
|
|
820
|
+
return () => {
|
|
821
|
+
window.removeEventListener('resize', update);
|
|
822
|
+
window.removeEventListener('scroll', update, true);
|
|
823
|
+
};
|
|
824
|
+
}, [open]);
|
|
825
|
+
|
|
826
|
+
useEffect(() => {
|
|
827
|
+
if (!open) return undefined;
|
|
828
|
+
const onPointerDown = (event) => {
|
|
829
|
+
if (switching !== null) return;
|
|
830
|
+
const target = event.target;
|
|
831
|
+
if (panelRef.current !== null && panelRef.current.contains(target)) return;
|
|
832
|
+
if (buttonRef.current !== null && buttonRef.current.contains(target)) return;
|
|
833
|
+
setOpen(false);
|
|
834
|
+
};
|
|
835
|
+
const onKeyDown = (event) => {
|
|
836
|
+
if (event.key !== 'Escape' || switching !== null) return;
|
|
837
|
+
event.preventDefault();
|
|
838
|
+
setOpen(false);
|
|
839
|
+
};
|
|
840
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
841
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
842
|
+
return () => {
|
|
843
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
844
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
845
|
+
};
|
|
846
|
+
}, [open, switching]);
|
|
847
|
+
|
|
848
|
+
if (path.length === 0 || status === null || status.isRepo !== true) return null;
|
|
849
|
+
|
|
850
|
+
const current = status.detached === true
|
|
851
|
+
? t('branch.detached') + (typeof status.oid === 'string' && status.oid.length > 0 ? ' · ' + status.oid : '')
|
|
852
|
+
: (typeof status.branch === 'string' && status.branch.length > 0 ? status.branch : 'HEAD');
|
|
853
|
+
const local = Array.isArray(status.local) ? status.local : [];
|
|
854
|
+
const remote = Array.isArray(status.remote) ? status.remote : [];
|
|
855
|
+
const list = tab === 'remote' ? remote : local;
|
|
856
|
+
const needle = query.trim().toLowerCase();
|
|
857
|
+
const filtered = needle.length === 0 ? list : list.filter((name) => name.toLowerCase().includes(needle));
|
|
858
|
+
const busy = switching !== null;
|
|
859
|
+
|
|
860
|
+
const openMenu = () => {
|
|
861
|
+
if (busy) return;
|
|
862
|
+
setOpen(true);
|
|
863
|
+
setError('');
|
|
864
|
+
setQuery('');
|
|
865
|
+
void load(path, { quiet: true });
|
|
866
|
+
};
|
|
867
|
+
const closeMenu = () => {
|
|
868
|
+
if (!busy) setOpen(false);
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const choose = async (name, isRemote) => {
|
|
872
|
+
if (busy) return;
|
|
873
|
+
const targetPath = path;
|
|
874
|
+
setSwitching(name);
|
|
875
|
+
setError('');
|
|
876
|
+
try {
|
|
877
|
+
const next = await branchRequest(BRANCH_SWITCH_ENDPOINT, { path: targetPath, branch: name, remote: isRemote });
|
|
878
|
+
if (selection !== undefined && typeof selection.get === 'function' && selection.get() !== targetPath) return;
|
|
879
|
+
setStatus(next);
|
|
880
|
+
setOpen(false);
|
|
881
|
+
} catch (failure) {
|
|
882
|
+
setError(failure instanceof Error && failure.message.length > 0 ? failure.message : t('branch.failed'));
|
|
883
|
+
} finally {
|
|
884
|
+
setSwitching(null);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
const menu = h('div', {
|
|
889
|
+
ref: panelRef,
|
|
890
|
+
className: 'wsc-branch-menu',
|
|
891
|
+
role: 'dialog',
|
|
892
|
+
'aria-label': t('branch.title'),
|
|
893
|
+
style: position === null ? { visibility: 'hidden' } : { left: position.left + 'px', top: position.top + 'px', width: position.width + 'px' }
|
|
894
|
+
},
|
|
895
|
+
h('div', { className: 'wsc-branch-head' },
|
|
896
|
+
h('span', { className: 'wsc-branch-headIcon' }, loading ? h('span', { className: 'wsc-spinner' }) : h(GitBranchIcon, null)),
|
|
897
|
+
h('div', { className: 'wsc-branch-headMain' },
|
|
898
|
+
h('div', { className: 'wsc-branch-headTitle' }, t('branch.current')),
|
|
899
|
+
h('div', { className: 'wsc-branch-headName', title: current }, current)
|
|
900
|
+
),
|
|
901
|
+
h('button', { type: 'button', className: 'wsc-branch-close', 'aria-label': t('branch.close'), disabled: busy, onClick: closeMenu }, h(CloseIcon, null))
|
|
902
|
+
),
|
|
903
|
+
h('div', { className: 'wsc-branch-tabs' },
|
|
904
|
+
h('button', { type: 'button', className: 'wsc-branch-tab', 'data-active': tab === 'local' ? 'true' : 'false', onClick: () => { setTab('local'); setQuery(''); } },
|
|
905
|
+
t('branch.local'), h('span', { className: 'wsc-branch-count' }, String(local.length))),
|
|
906
|
+
h('button', { type: 'button', className: 'wsc-branch-tab', 'data-active': tab === 'remote' ? 'true' : 'false', onClick: () => { setTab('remote'); setQuery(''); } },
|
|
907
|
+
t('branch.remote'), h('span', { className: 'wsc-branch-count' }, String(remote.length)))
|
|
908
|
+
),
|
|
909
|
+
h('div', { className: 'wsc-branch-searchRow' },
|
|
910
|
+
h('input', {
|
|
911
|
+
className: 'wsc-branch-search',
|
|
912
|
+
value: query,
|
|
913
|
+
autoFocus: true,
|
|
914
|
+
placeholder: t('branch.search'),
|
|
915
|
+
'aria-label': t('branch.search'),
|
|
916
|
+
spellCheck: false,
|
|
917
|
+
autoComplete: 'off',
|
|
918
|
+
onChange: (event) => setQuery(event.target.value)
|
|
919
|
+
})
|
|
920
|
+
),
|
|
921
|
+
h('div', { className: 'wsc-branch-list', role: 'listbox', 'aria-label': tab === 'remote' ? t('branch.remote') : t('branch.local') },
|
|
922
|
+
filtered.length === 0
|
|
923
|
+
? h('div', { className: 'wsc-branch-empty' }, t('branch.empty'))
|
|
924
|
+
: filtered.map((name) => {
|
|
925
|
+
const isCurrent = tab === 'local' && name === status.branch && status.detached !== true;
|
|
926
|
+
return h('button', {
|
|
927
|
+
key: tab + '-' + name,
|
|
928
|
+
type: 'button',
|
|
929
|
+
role: 'option',
|
|
930
|
+
'aria-selected': isCurrent ? 'true' : 'false',
|
|
931
|
+
className: 'wsc-branch-row',
|
|
932
|
+
'data-current': isCurrent ? 'true' : 'false',
|
|
933
|
+
title: tab === 'remote' ? t('branch.remoteHint') : name,
|
|
934
|
+
disabled: busy,
|
|
935
|
+
onClick: () => { void choose(name, tab === 'remote'); }
|
|
936
|
+
},
|
|
937
|
+
h('span', { className: 'wsc-branch-rowIcon' }, h(GitBranchIcon, null)),
|
|
938
|
+
h('span', { className: 'wsc-branch-rowName' }, name),
|
|
939
|
+
isCurrent ? h('span', { className: 'wsc-branch-badge' }, t('branch.current')) : null
|
|
940
|
+
);
|
|
941
|
+
})
|
|
942
|
+
),
|
|
943
|
+
error.length > 0 ? h('div', { className: 'wsc-branch-error', role: 'alert' }, error) : null,
|
|
944
|
+
tab === 'remote' ? h('div', { className: 'wsc-branch-note' }, t('branch.remoteHint')) : null,
|
|
945
|
+
busy ? h('div', { className: 'wsc-branch-loading' },
|
|
946
|
+
h('span', { className: 'wsc-spinner' }),
|
|
947
|
+
h('div', { className: 'wsc-branch-loadingText' }, t('branch.switching')),
|
|
948
|
+
h('div', { className: 'wsc-branch-loadingDetail' }, switching)
|
|
949
|
+
) : null
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
return h(React.Fragment, null,
|
|
953
|
+
h('button', {
|
|
954
|
+
ref: buttonRef,
|
|
955
|
+
type: 'button',
|
|
956
|
+
className: 'wsc-branch-chip',
|
|
957
|
+
'data-open': open ? 'true' : 'false',
|
|
958
|
+
'data-busy': busy ? 'true' : 'false',
|
|
959
|
+
disabled: busy,
|
|
960
|
+
title: t('branch.title') + ' · ' + current,
|
|
961
|
+
'aria-label': t('branch.title') + ': ' + current,
|
|
962
|
+
'aria-haspopup': 'dialog',
|
|
963
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
964
|
+
onClick: () => {
|
|
965
|
+
if (open) closeMenu();
|
|
966
|
+
else openMenu();
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
busy ? h('span', { className: 'wsc-spinner' }) : h('span', { className: 'wsc-branch-chipIcon' }, h(GitBranchIcon, null)),
|
|
970
|
+
h('span', { className: 'wsc-branch-name' }, busy ? t('branch.switching') : current),
|
|
971
|
+
h('span', { className: 'wsc-branch-caret' }, h(ChevronDownIcon, null))
|
|
972
|
+
),
|
|
973
|
+
open && position !== null ? (createPortal !== null ? createPortal(menu, document.body) : menu) : null
|
|
974
|
+
);
|
|
975
|
+
}
|
|
588
976
|
//#endregion
|
|
589
977
|
|
|
590
978
|
//#region overlay switcher
|
|
@@ -771,6 +1159,7 @@ window.__ModuleLoader__.load({
|
|
|
771
1159
|
ctx.effect(() => installStyles(), 'workspace-combobox: styles');
|
|
772
1160
|
const t = ctx.locale.bind(NS);
|
|
773
1161
|
const store = createStore({ open: false });
|
|
1162
|
+
const selectedPathStore = createPathStore();
|
|
774
1163
|
const workspaces = ctx.get('workspaces');
|
|
775
1164
|
const uiWorkspace = ctx.get('uiWorkspace');
|
|
776
1165
|
const remote = ctx.get('remote');
|
|
@@ -832,6 +1221,7 @@ window.__ModuleLoader__.load({
|
|
|
832
1221
|
createWorkspace: actions.createWorkspace
|
|
833
1222
|
},
|
|
834
1223
|
getHome,
|
|
1224
|
+
selection: selectedPathStore,
|
|
835
1225
|
shortcutLabel
|
|
836
1226
|
})
|
|
837
1227
|
}, HeroWorkspaceCombobox);
|
|
@@ -840,6 +1230,20 @@ window.__ModuleLoader__.load({
|
|
|
840
1230
|
return () => {};
|
|
841
1231
|
}
|
|
842
1232
|
});
|
|
1233
|
+
ctx.slots.inject('conversation.hero.options', () => {
|
|
1234
|
+
try {
|
|
1235
|
+
return ctx.slots.register({
|
|
1236
|
+
name: 'conversation.hero.options',
|
|
1237
|
+
id: 'workspace-combobox.branch',
|
|
1238
|
+
order: 20,
|
|
1239
|
+
locale: NS,
|
|
1240
|
+
inject: () => ({ selection: selectedPathStore })
|
|
1241
|
+
}, BranchSelector);
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
console.warn('workspace combobox: branch selector registration failed', error);
|
|
1244
|
+
return () => {};
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
843
1247
|
}
|
|
844
1248
|
//#endregion
|
|
845
1249
|
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,223 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
var STATUS_ROUTE = "/api/workspace-git";
|
|
6
|
+
var SWITCH_ROUTE = "/api/workspace-git/switch";
|
|
7
|
+
var STATUS_TIMEOUT_MS = 1e4;
|
|
8
|
+
var SWITCH_TIMEOUT_MS = 60000;
|
|
9
|
+
var MAX_BUFFER = 8 * 1024 * 1024;
|
|
10
|
+
var MAX_PATH_LENGTH = 4096;
|
|
11
|
+
var MAX_BRANCH_LENGTH = 255;
|
|
12
|
+
var MAX_ERROR_LENGTH = 500;
|
|
13
|
+
var activeSwitches = new Set;
|
|
14
|
+
|
|
15
|
+
class BadRequest extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "BadRequest";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function ok(value) {
|
|
22
|
+
return { ok: true, value };
|
|
23
|
+
}
|
|
24
|
+
function fail(error) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
error: {
|
|
28
|
+
code: "workspace-git",
|
|
29
|
+
message: error instanceof Error ? error.message : String(error),
|
|
30
|
+
details: {}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function runGit(cwd, args, signal, timeoutMs = STATUS_TIMEOUT_MS) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
execFile("git", ["-C", cwd, ...args], {
|
|
37
|
+
cwd,
|
|
38
|
+
timeout: timeoutMs,
|
|
39
|
+
maxBuffer: MAX_BUFFER,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
44
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
45
|
+
LC_ALL: "C",
|
|
46
|
+
LANG: "C"
|
|
47
|
+
},
|
|
48
|
+
signal
|
|
49
|
+
}, (error, stdout, stderr) => {
|
|
50
|
+
resolve({
|
|
51
|
+
ok: error === null || error === undefined,
|
|
52
|
+
stdout: String(stdout ?? ""),
|
|
53
|
+
stderr: String(stderr ?? ""),
|
|
54
|
+
aborted: error !== null && error !== undefined && error.name === "AbortError",
|
|
55
|
+
code: error !== null && error !== undefined && typeof error.code === "number" ? error.code : 0
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function parseBranches(text) {
|
|
61
|
+
const unique = new Set;
|
|
62
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
63
|
+
const name = line.trim();
|
|
64
|
+
if (name.length === 0)
|
|
65
|
+
continue;
|
|
66
|
+
unique.add(name);
|
|
67
|
+
}
|
|
68
|
+
return [...unique].sort((left, right) => left.localeCompare(right, "en"));
|
|
69
|
+
}
|
|
70
|
+
function parseRemoteBranches(text) {
|
|
71
|
+
return parseBranches(text).filter((name) => !name.endsWith("/HEAD"));
|
|
72
|
+
}
|
|
73
|
+
function gitError(result) {
|
|
74
|
+
if (result.aborted)
|
|
75
|
+
return "git command was cancelled";
|
|
76
|
+
const lines = String(result.stderr ?? "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
77
|
+
const message = lines.slice(-3).join(" ");
|
|
78
|
+
return (message.length > 0 ? message : "git switch failed").slice(0, MAX_ERROR_LENGTH);
|
|
79
|
+
}
|
|
80
|
+
async function readStatus(path, signal) {
|
|
81
|
+
const probe = await runGit(path, ["rev-parse", "--is-inside-work-tree"], signal);
|
|
82
|
+
if (probe.ok !== true || probe.stdout.trim() !== "true") {
|
|
83
|
+
return { isRepo: false, checkedAt: Date.now() };
|
|
84
|
+
}
|
|
85
|
+
const [current, oid, local, remote] = await Promise.all([
|
|
86
|
+
runGit(path, ["branch", "--show-current"], signal),
|
|
87
|
+
runGit(path, ["rev-parse", "--short", "HEAD"], signal),
|
|
88
|
+
runGit(path, ["for-each-ref", "--format=%(refname:short)", "refs/heads"], signal),
|
|
89
|
+
runGit(path, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"], signal)
|
|
90
|
+
]);
|
|
91
|
+
const currentName = current.ok ? current.stdout.trim() : "";
|
|
92
|
+
const oidValue = oid.ok ? oid.stdout.trim() : "";
|
|
93
|
+
const detached = currentName.length === 0 || currentName === "HEAD";
|
|
94
|
+
return {
|
|
95
|
+
isRepo: true,
|
|
96
|
+
branch: detached ? null : currentName,
|
|
97
|
+
oid: oidValue.length > 0 ? oidValue : null,
|
|
98
|
+
detached,
|
|
99
|
+
local: local.ok ? parseBranches(local.stdout) : [],
|
|
100
|
+
remote: remote.ok ? parseRemoteBranches(remote.stdout) : [],
|
|
101
|
+
checkedAt: Date.now()
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async function validatePath(ctx, value) {
|
|
105
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_PATH_LENGTH) {
|
|
106
|
+
throw new BadRequest("workspace path must be a non-empty absolute directory path");
|
|
107
|
+
}
|
|
108
|
+
if (!isAbsolute(value))
|
|
109
|
+
throw new BadRequest("workspace path must be absolute");
|
|
110
|
+
const info = await stat(value).catch(() => {
|
|
111
|
+
return;
|
|
112
|
+
});
|
|
113
|
+
if (info === undefined || !info.isDirectory()) {
|
|
114
|
+
throw new BadRequest("workspace path is not a directory");
|
|
115
|
+
}
|
|
116
|
+
const registry = ctx.get("workspaceRegistry");
|
|
117
|
+
if (registry !== undefined && typeof registry.list === "function") {
|
|
118
|
+
let owned = [];
|
|
119
|
+
try {
|
|
120
|
+
owned = registry.list().map((workspace) => workspace !== null && typeof workspace === "object" ? workspace.path : undefined).filter((path) => typeof path === "string");
|
|
121
|
+
} catch {
|
|
122
|
+
owned = [];
|
|
123
|
+
}
|
|
124
|
+
if (owned.length > 0 && !owned.includes(value)) {
|
|
125
|
+
throw new BadRequest("workspace is not registered");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
async function readPayload(request) {
|
|
131
|
+
try {
|
|
132
|
+
const payload = await request.json();
|
|
133
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
134
|
+
throw new BadRequest("body must be a JSON object");
|
|
135
|
+
}
|
|
136
|
+
return payload;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof BadRequest)
|
|
139
|
+
throw error;
|
|
140
|
+
throw new BadRequest("body must be valid JSON");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function switchBranch(ctx, path, branch, remote, signal) {
|
|
144
|
+
if (typeof branch !== "string" || branch.length === 0 || branch.length > MAX_BRANCH_LENGTH) {
|
|
145
|
+
throw new BadRequest("branch name is invalid");
|
|
146
|
+
}
|
|
147
|
+
if (branch.startsWith("-") || /[\s\0]/.test(branch)) {
|
|
148
|
+
throw new BadRequest("branch name is invalid");
|
|
149
|
+
}
|
|
150
|
+
const before = await readStatus(path, signal);
|
|
151
|
+
if (before.isRepo !== true)
|
|
152
|
+
throw new BadRequest("workspace is not a git repository");
|
|
153
|
+
const available = remote === true ? before.remote : before.local;
|
|
154
|
+
if (!available.includes(branch)) {
|
|
155
|
+
throw new BadRequest(remote === true ? "remote branch is not available" : "local branch is not available");
|
|
156
|
+
}
|
|
157
|
+
if (activeSwitches.has(path)) {
|
|
158
|
+
throw new BadRequest("a branch switch is already running for this workspace");
|
|
159
|
+
}
|
|
160
|
+
activeSwitches.add(path);
|
|
161
|
+
try {
|
|
162
|
+
const args = remote === true ? ["switch", "--track", branch] : ["switch", branch];
|
|
163
|
+
let result = await runGit(path, args, signal, SWITCH_TIMEOUT_MS);
|
|
164
|
+
if (result.ok !== true) {
|
|
165
|
+
const fallbackArgs = remote === true ? ["checkout", "--track", branch] : ["checkout", branch];
|
|
166
|
+
const fallback = await runGit(path, fallbackArgs, signal, SWITCH_TIMEOUT_MS);
|
|
167
|
+
if (fallback.ok !== true)
|
|
168
|
+
throw new Error(gitError(result));
|
|
169
|
+
result = fallback;
|
|
170
|
+
}
|
|
171
|
+
return await readStatus(path, signal);
|
|
172
|
+
} finally {
|
|
173
|
+
activeSwitches.delete(path);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function apply(ctx) {
|
|
177
|
+
ctx.inject(["connection"], (scoped) => {
|
|
178
|
+
const connection = scoped.get("connection");
|
|
179
|
+
if (connection === undefined || connection === null)
|
|
180
|
+
return;
|
|
181
|
+
const fetchRoutes = connection.fetch;
|
|
182
|
+
if (fetchRoutes === undefined || fetchRoutes === null || typeof fetchRoutes.register !== "function")
|
|
183
|
+
return;
|
|
184
|
+
scoped.effect(() => fetchRoutes.register({
|
|
185
|
+
path: STATUS_ROUTE,
|
|
186
|
+
methods: ["POST"],
|
|
187
|
+
requestBody: "buffered",
|
|
188
|
+
fetch: async (request) => {
|
|
189
|
+
try {
|
|
190
|
+
const payload = await readPayload(request);
|
|
191
|
+
const path = await validatePath(ctx, payload.path);
|
|
192
|
+
return Response.json(ok(await readStatus(path, request.signal)));
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return Response.json(fail(error));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}), "workspace-combobox: /api/workspace-git route");
|
|
198
|
+
scoped.effect(() => fetchRoutes.register({
|
|
199
|
+
path: SWITCH_ROUTE,
|
|
200
|
+
methods: ["POST"],
|
|
201
|
+
requestBody: "buffered",
|
|
202
|
+
fetch: async (request) => {
|
|
203
|
+
try {
|
|
204
|
+
const payload = await readPayload(request);
|
|
205
|
+
const path = await validatePath(ctx, payload.path);
|
|
206
|
+
const remote = payload.remote === true;
|
|
207
|
+
return Response.json(ok(await switchBranch(ctx, path, payload.branch, remote, request.signal)));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return Response.json(fail(error));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}), "workspace-combobox: /api/workspace-git/switch route");
|
|
213
|
+
});
|
|
214
|
+
}
|
|
3
215
|
export {
|
|
4
|
-
|
|
216
|
+
STATUS_ROUTE,
|
|
217
|
+
SWITCH_ROUTE,
|
|
218
|
+
apply,
|
|
219
|
+
parseBranches,
|
|
220
|
+
parseRemoteBranches,
|
|
221
|
+
readStatus,
|
|
222
|
+
switchBranch
|
|
5
223
|
};
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Workspace Combobox",
|
|
4
|
-
"description": "Turns the New Session workspace picker into a combobox with mixed Chinese/English pinyin search, and opens a frame-wide workspace switcher with Ctrl+."
|
|
4
|
+
"description": "Turns the New Session workspace picker into a combobox with mixed Chinese/English pinyin search, adds a Git branch picker beside the agent preset with local/remote search and a visible switching state, and opens a frame-wide workspace switcher with Ctrl+."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qzsy/dsh-workspace-combobox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pinyin-aware workspace combobox for the New Session picker, plus a Ctrl+. frame-wide workspace switcher.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pinyin-aware workspace combobox for the New Session picker, plus a Git branch picker with local/remote branch switching and a Ctrl+. frame-wide workspace switcher.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "qzsy",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"deepseek-harness",
|
|
16
16
|
"plugin",
|
|
17
17
|
"workspace",
|
|
18
|
-
"pinyin"
|
|
18
|
+
"pinyin",
|
|
19
|
+
"git",
|
|
20
|
+
"branch"
|
|
19
21
|
],
|
|
20
22
|
"repository": {
|
|
21
23
|
"type": "git",
|