@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 CHANGED
@@ -1,76 +1,95 @@
1
1
  # @qzsy/dsh-workspace-combobox
2
2
 
3
- > Packaged from the local DSH plugin dsh-workspace-combobox; published under the @qzsy scope.
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
- ## Upstream README
11
+ ## Features
12
12
 
13
- # Workspace Combobox for DSH
13
+ ### Workspace combobox
14
14
 
15
- A persistent DeepSeek Harness Client plugin that upgrades the workspace chooser
16
- above the New Session composer:
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
- - **The picker becomes a combobox.** The dropdown that opens from the workspace
19
- chip is replaced by a focused search field plus a keyboard-driven list. It
20
- matches workspace **names and full paths** with one mixed-script query:
21
- Han characters (`群星`), full pinyin (`qunxing`), pinyin initials (`qx`),
22
- English subsequences (`flut`), and mixed forms (`qunxing-erp`, `群erp`).
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
- Sizing is deliberately split: the picker above the composer stays compact
32
- (single-line rows, 380 px wide), while the frame-wide dialog keeps the larger
33
- palette dimensions and full paths.
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 | A `conversation.hero.workspace` occupant registered at `priority: -1`. The slot registry renders the lowest priority, so it shadows the shipped picker panel registered at 0. The trigger chip, open/close state, `selectedId`, and `onPick` still come from the conversation owner. The panel renders through a React portal on `document.body`, like the shipped menu, so no transformed or clipped ancestor can displace it. |
40
- | Frame-wide switcher | A `shell.overlay` entry (`workspace-combobox.switcher`) driven by the `workspace.combobox` shortcut command. |
41
- | Search | The MIT [`text-search-engine`](https://github.com/cjinhuo/text-search-engine) browser build is vendored and injected into the client module, because the client module table cannot resolve npm packages at runtime. |
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
- Replacing the picker panel removes the shipped `conversation.hero.workspace.directoryFlow`
44
- hole, so this plugin also renders its own **Add workspace…** row: it calls
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
- ## Install
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
- Installed into the current profile with `plugin_manager install_bundle` and the
51
- absolute package directory. No build step is needed at install time: `client.js`
52
- is a plain-JS `__ModuleLoader__` module that only requires `react`, with the
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
- - `package.json` — bundle manifest (`dsh.bundle.patch`, `dsh.client`, display resources)
58
- - `cordis.patch.yml` — inserts the Host row
59
- - `index.js` — Host half (no-op; everything is client-side)
60
- - `client.js` — the combobox, the frame-wide switcher, the shortcut, and the
61
- vendored-engine region
62
- - `vendor/` — vendored engine build and its MIT license
63
- - `scripts/sync-vendor-engine.mjs` — regenerates the engine region after a
64
- vendor update
65
- - `locale/en.json`, `locale/zh.json` — Plugin Manager title/description
66
- - `icon.svg` — original artwork
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 above the composer belongs to the conversation owner and is
71
- not replaceable by a slot occupant, so the typing happens inside the opened
72
- panel (a true combobox), not in the chip itself.
73
- - Workspaces are ordered by latest session activity; the search ranking prefers
74
- title hits over path hits.
75
- - The picker replacement shadows shipped UI. Uninstalling the bundle restores the
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
- function apply() {}
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
- apply
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "工作区组合框",
4
- "description": "把新会话的工作区选择器换成支持中英混拼(汉字 / 全拼 / 首字母)检索的组合框,并可用 Ctrl+. 随时打开全局工作区切换器。"
4
+ "description": "把新会话的工作区选择器换成支持中英混拼(汉字 / 全拼 / 首字母)检索的组合框,并在标准模式右侧显示 Git 分支选择器;支持本地/远程分支搜索与带加载状态的快速切换,Ctrl+. 随时打开全局工作区切换器。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-workspace-combobox",
3
- "version": "0.1.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",