dsh-gitbar-lite 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ckarefulon
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.en.md ADDED
@@ -0,0 +1,274 @@
1
+ # dsh-gitbar-lite
2
+
3
+ **A GitHub Desktop-style Git panel for DSH**, living in DSH's **built-in right sidebar** — beside Files, Document and Chat.
4
+
5
+ Changed files, tick-to-stage, a Summary plus a collapsible Description, Commit and Commit and Push, one context button that is Push or Fetch depending on the branch, a discard that archives into the plugin's own trash, and a diff preview — all in that column.
6
+
7
+ > 中文:[README.md](./README.md)
8
+
9
+ ## Why a right-sidebar tab
10
+
11
+ The right column is already a permanent tab strip. Putting the panel there means it shares the session's context: **the repository it shows is the one the active session works in**, so switching sessions switches repositories without picking a directory again.
12
+
13
+ The column is narrow (300px minimum, and a narrow viewport makes it full-screen), so the panel is built for that width: a single-column file list, paths split into name and directory, and a commit box that is one Summary field plus a small triangle that reveals the Description.
14
+
15
+ ## What it does
16
+
17
+ | | |
18
+ |---|---|
19
+ | Change list | Modified, added, deleted, renamed and untracked, each with its `+n −n` line counts |
20
+ | Ticking | **A tick is a real `git add`**; unticking is `git restore --staged`. What a terminal's `git status` shows is the same thing |
21
+ | Half-staged files | When the index and the worktree are both dirty the tick renders indeterminate; either click restates the file as a whole |
22
+ | Summary / Description | Summary is one always-visible line; the triangle at its right reveals Description |
23
+ | Commit | Commits exactly what is ticked — because ticking wrote the index |
24
+ | Commit and Push | Commits, then pushes. **A failed push never claims the commit failed**: the commit happened, and the panel says "committed `abc1234`, but the push failed" |
25
+ | Context button | **Push** when local commits are unpushed, **Publish** when the branch has no upstream, **Fetch** otherwise |
26
+ | Discard | One file or all of them; the diff is archived *before* anything is restored, and the confirmation offers "delete for good" |
27
+ | Trash | Its own tab. Every discard keeps a diff, recorded **against the commit it was taken from** (`abc1234 initial commit`) |
28
+ | Diff preview | Double-click a file, or a trash entry, to open a diff tab in the column — **one tab, ever**, re-navigated in place |
29
+ | Repository binding | A workspace that is itself a repository binds automatically and is remembered; otherwise pick from the repositories found under the workspace, or type a path |
30
+
31
+ ## Requirements
32
+
33
+ | | |
34
+ |---|---|
35
+ | DSH `>= 0.2.0-rc.2` | The right-sidebar tab API is this version's behaviour |
36
+ | Node `>= 22` | The host half uses `AbortSignal.any` and `node:test` |
37
+ | **A local `git`** | Found on `PATH` by default. When it cannot be found the panel says so plainly instead of failing silently; point `gitPath` at it if it lives somewhere unusual |
38
+ | A credential | Push uses **the system git's credential helper**. Push once successfully from a terminal and the plugin can push too |
39
+
40
+ The plugin stores no credential of its own, and the host half reads no secret out of `$DSH_HOME`.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ dsh plugin --profile <your profile> add dsh-gitbar-lite
46
+ ```
47
+
48
+ Restart DSH. Open the right column in a session; its guide page gains a **"Git changes"** card, and that opens the panel.
49
+
50
+ <details>
51
+ <summary>Installing from a local clone / uninstalling / what to do when DSH will not start</summary>
52
+
53
+ **From a local directory** (use this while changing the code):
54
+
55
+ ```sh
56
+ dsh plugin --profile desktop add link:/absolute/path/dsh-gitbar-lite
57
+ ```
58
+
59
+ **Uninstall**:
60
+
61
+ ```sh
62
+ dsh plugin --profile <your profile> remove dsh-gitbar-lite
63
+ ```
64
+
65
+ **DSH will not start after installing**: run `node scripts/check-client-bundle.mjs .` first. DSH reads
66
+ `exports["./client"]` as an **exact file path** — no extension completion, no directory fallback — and a manifest
67
+ pointing at a file that does not exist raises `MissingClientBundleError` and **aborts the whole Host boot** rather
68
+ than merely hiding the plugin. That script reproduces the same resolution so you do not have to find out by restarting.
69
+
70
+ </details>
71
+
72
+ ## Using it
73
+
74
+ ```
75
+ right column, guide page
76
+ ┌──────────────────────────────────┐
77
+ │ ▸ Git changes │ ← the guide card
78
+ │ Review changes, tick what… │
79
+ └──────────────────────────────────┘
80
+ ↓
81
+ ┌──────────────────────────────────┐
82
+ │ dsh-gitbar-lite main ↑1 ↓0 │ ← repository / branch / ahead·behind
83
+ │ [Push] [↻] [🗄] [⑂] │ ← context button, refresh, trash, rebind
84
+ │ Changes 5 files · 2 ticked [Discard all] │
85
+ │ ☑ M a.txt +3 −1 ↩│ ← double-click = diff; ↩ = discard this file
86
+ │ ▣ M src/big.ts +12 −4 ↩│ ← ▣ = half staged
87
+ │ ☐ ? new note.md +7 ↩│
88
+ │ ☐ D docs/old.md −31 ↩│
89
+ │ ┌────────────────────────────┐ ˅ │ ← ˅ reveals Description
90
+ │ │ Summary (required) │ │
91
+ │ └────────────────────────────┘ │
92
+ │ [ Commit ] [ Commit and Push ]│
93
+ └──────────────────────────────────┘
94
+ ```
95
+
96
+ - **Ticking** writes the index; unticking takes it back out. An indeterminate file ticks as a whole, and a second click unticks it as a whole.
97
+ - **Double-click a row** opens the diff tab; double-clicking another file **reuses that same tab**.
98
+ - **Discard** (the ↩ at the end of a row, or "Discard all") opens a confirmation:
99
+
100
+ ```
101
+ Discard the changes to "a.txt"?
102
+ · a.txt
103
+ ☐ Delete for good (keep no copy)
104
+ This diff is stored in the plugin's trash, so you can still see what was discarded.
105
+ [ Cancel ] [ Discard ]
106
+ ```
107
+
108
+ **"Delete for good" is unticked by default**, so the diff is archived; tick it and nothing is kept. The box
109
+ remembers your last answer.
110
+ - **The trash** (🗄) lists entries by time: how long ago, one file or all changes, the file count, `+n −n`, and
111
+ `against abc1234 initial commit`. Click one for its diff, 🗑 at the end of a row deletes it, and "Empty the trash"
112
+ clears them (only the bound repository, or everything with "Show every repository" ticked).
113
+ - **Rebind** (⑂) clears the current binding. A workspace that is a repository re-binds itself; otherwise the picker comes back.
114
+
115
+ ### What discarding actually does
116
+
117
+ The order is fixed and cannot be swapped:
118
+
119
+ 1. Build a diff of the current changes. Tracked files come from `git diff HEAD`; **untracked new files are turned
120
+ into a synthesized new-file diff by the plugin**, because `git diff HEAD` cannot see them and discarding an
121
+ untracked file deletes it — without synthesizing, the archive would be a bare "one file was deleted".
122
+ 2. Store it in the trash, unless "delete for good" was ticked.
123
+ 3. Tracked paths: `git restore --source=HEAD --staged --worktree`. Untracked paths: delete the file, then prune the
124
+ directories that the discard emptied.
125
+
126
+ > **A conflicted file is refused**, with one sentence. Running `git restore` to HEAD in the middle of a merge can
127
+ > disturb the merge state, and that risk is not worth saving a sentence.
128
+
129
+ ## Configuration
130
+
131
+ | field | default | |
132
+ |---|---|---|
133
+ | `gitPath` | `""` | Explicit git executable; empty means "look on `PATH`" |
134
+ | `stateDir` | `""` | Plugin state directory; empty means `<DSH_HOME>/dsh-gitbar-lite` |
135
+ | `timeoutMs` | `30000` | Per-command timeout. Raise it for a slow remote |
136
+ | `maxDiffBytes` | `524288` | Ceiling on one archived diff; it is cut on a **line** boundary and the dropped lines are recorded |
137
+ | `maxFileBytes` | `2097152` | Larger untracked files are listed but not read, archived, or diffed |
138
+ | `trashLimit` | `200` | Trash entries kept; the oldest are dropped along with their diff files |
139
+ | `scanDepth` | `3` | Directory depth the picker scans below the workspace root |
140
+
141
+ Every field is volatile, so a change on the settings page takes effect without a restart.
142
+
143
+ **Where state lives**: `<DSH_HOME>/dsh-gitbar-lite/` (falling back to `~/.dsh`) — `bindings.json` (workspace →
144
+ repository), `prefs.json` (the two remembered choices), and `trash/index.json` plus `trash/<id>.diff`. Every write
145
+ goes through a temp file and a rename, so a crash mid-write cannot truncate a file.
146
+
147
+ ## How it works
148
+
149
+ ```
150
+ lib/shared.js wire contract literals (the browser half repeats them; the self-check compares)
151
+ lib/index.js entry: Config, apply, one route on connection.fetch
152
+ lib/host/git.js git execution via ctx.subprocess; resolves the executable, bounds output, scrubs env
153
+ lib/host/parse.js porcelain v2 / numstat parsing (pure)
154
+ lib/host/diff.js diff synthesis, line-boundary truncation, per-file splitting (pure)
155
+ lib/host/repo.js status / stage / commit / push / fetch / fileDiff / discard
156
+ lib/host/store.js bindings, preferences, trash (atomic writes, serialized per path)
157
+ lib/host/workspace.js session -> workspace directory
158
+ lib/host/routes.js the single /api route, action dispatch, error-code mapping
159
+ lib/client.js browser half: three tab types, hand-written, no build step
160
+ ```
161
+
162
+ The decisions worth writing down:
163
+
164
+ - **One `/api` route with an `action` field**, not one route per operation. `connection.fetch` matches on the exact
165
+ pathname and every call already carries a `sessionId`; separate routes would spread fifteen path literals across
166
+ two languages.
167
+ - **It must register on `connection.fetch`, never as a `webServer` exact route.** The web server resolves exact
168
+ routes *before* its `/api` prefix route, and the Host/Origin fence, the browser-cookie check and the operator peer
169
+ all hang off that prefix route — so an exact route on the same path is an unauthenticated git surface. Both the
170
+ tests and the self-check assert the carrier, and the self-check **proves its own assertion is sensitive** by running
171
+ the predicate against a deliberately broken copy of the source.
172
+ - **The diff tab is a `dsh-resource://` address, not a page kind.** Re-opening a resource address makes the host
173
+ reveal the tab that is already open and re-navigate it with the new `params`; a page kind reuses only within the
174
+ active dock pane, so a floated tab would get a second copy. The fixed address *is* "one diff tab, ever".
175
+ - **Commit messages go in over stdin (`-F -`), not `-m`**: a Summary containing a quote, a newline, or a leading
176
+ dash is then just text, not an argument.
177
+ - **`:(literal)` pathspecs**: without them, discarding a file named `docs` would also reach everything under `docs/`,
178
+ and `a[1].txt` would be treated as a pattern.
179
+ - **No `@deepseek-ai/dsh-client-ui-primitives` import.** The browser half requires only `react` — a throwing import
180
+ blanks the whole slot entry, and the official authoring checklist asks a client bundle to import no Harness Client
181
+ package. The few controls it would have supplied are drawn here against the same design tokens, and the icons are
182
+ the host's own artwork (`IconBranchOutlineRegular` and friends) copied path for path, so the column does not look
183
+ foreign.
184
+
185
+ The full wire contract, the state-file formats, why the discard order is what it is, and what has *not* been verified
186
+ are in [`docs/CONTRACT.md`](./docs/CONTRACT.md).
187
+
188
+ ## Verification
189
+
190
+ ```sh
191
+ npm test # unit + real-git e2e + artifact self-check
192
+ npm run test:e2e # the real-git suite alone (18 independent repositories, 224 assertions)
193
+ npm run check # artifact self-check alone (catches boot-breaking mistakes without restarting DSH)
194
+ npm run test:live # probe a running DSH (not part of npm test; needs the app running)
195
+ ```
196
+
197
+ | suite | covers |
198
+ |---|---|
199
+ | `test/parse.test.mjs` | porcelain v2 and numstat parsing, including a rename, a staged delete, a conflict, a non-ASCII path and a path with a space |
200
+ | `test/diff.test.mjs` | diff synthesis (missing trailing newline, binary, CRLF), truncation that never splits a character, per-file splitting |
201
+ | `test/store.test.mjs` | binding-key normalization, bindings/preferences/trash round-trips, corruption tolerance, no lost update under concurrent writers |
202
+ | `test/index.test.mjs` | Config fields and defaults, **the route carrier and single registration**, error-code mapping |
203
+ | `test/client.test.mjs` | the real bundle loaded from the manifest's exact path; the three tab types' ids/kinds/guide/keepMounted; every slot key being a registered id; both dictionaries and every failure code; the three hook-free components asserted on their element trees |
204
+ | `test/e2e.mjs` | real git through the real handler and route layers: ticking really writes the index (read back with `git status`), commit, push to a local bare remote, a rejected push, discarding one file / everything / a rename / a staged delete, conflict refusal, trash add-delete-clear, per-repository filtering, scan depth |
205
+ | `test/check.mjs` | the manifest's targets all exist and reach the tarball, the patch layer is one row with no secret, the two copies of the wire contract agree, the dictionaries agree with `locale/*.json`, every `t('literal')` resolves, the README and workflow are present and say the right things |
206
+ | `scripts/check-client-bundle.mjs` | reproduces client-modules' resolution: does the file `exports["./client"]` names actually exist |
207
+
208
+ **There is no real-browser harness**, deliberately: this plugin measures no DOM, augments no host DOM, and portals
209
+ nothing to `document.body`, and the fixture's fidelity is itself a cost — plugins in this family have had a bug
210
+ frozen green because the fixture copied a CSS rule wrong. The three components with no hooks in them *are* asserted
211
+ on their rendered trees, which covers the rules that matter (a tick reflects the index, the confirmation defaults to
212
+ keeping a copy, the picker lists its candidates). If the UI ever grows something hover-dependent or measured, that
213
+ judgement should be revisited; it is recorded in `docs/CONTRACT.md`.
214
+
215
+ ## Known limitations
216
+
217
+ - One repository per workspace, one remote per push (`origin` when present). No submodules, worktrees, branch
218
+ switching, history list, or stash.
219
+ - Fetch runs `git fetch --prune`, which deletes remote-tracking refs for branches the remote no longer has (matching
220
+ GitHub Desktop).
221
+ - Your commit hooks run. A hook that blocks is your hook; `timeoutMs` is the only backstop.
222
+ - The trash keeps **diffs, not files**. Recovery means applying the archived diff by hand — a restore button was
223
+ agreed out of scope.
224
+ - Binary content is not archived; the diff says `Binary files … differ`. That is honest, and it is not a backup.
225
+ - An untracked file larger than `maxFileBytes` is deleted with only a note in the archive, and the confirmation box
226
+ does not yet warn about that specifically.
227
+ - Discarding an untracked file deletes it outright (not into the OS recycle bin). That is the agreed design: the
228
+ plugin's own trash receives it, and with "delete for good" unticked its content is still readable there.
229
+
230
+ ## Publishing (maintainers)
231
+
232
+ The version number is the release button:
233
+
234
+ 1. Change `version` in `package.json`.
235
+ 2. Push to `main`.
236
+
237
+ `.github/workflows/publish.yml` runs `npm test`, then asks `npm view <name>@<version>` — **if that version is already
238
+ on the registry it skips quietly** (a green no-op that tells you to bump). Forgetting to bump therefore neither
239
+ republishes silently nor shows up as a red run.
240
+
241
+ Authentication is npm **Trusted Publishing (OIDC)**: there is no token secret anywhere, and `id-token: write` is the
242
+ whole credential.
243
+
244
+ <details>
245
+ <summary>One-time setup: bind a Trusted Publisher on npmjs.com</summary>
246
+
247
+ On the package page → Settings → Trusted Publisher:
248
+
249
+ | field | value |
250
+ |---|---|
251
+ | Publisher | GitHub Actions |
252
+ | Repository | `Ckarefulon/dsh-gitbar-lite` |
253
+ | Workflow filename | `publish.yml` |
254
+ | Environment | (leave empty) |
255
+
256
+ **The first release must be a manual `npm publish`** — a Trusted Publisher can only be bound to a package name that
257
+ already exists on the registry.
258
+
259
+ The workflow deliberately does **not** use `actions/setup-node`'s `registry-url`: it writes
260
+ `_authToken=${NODE_AUTH_TOKEN}` into `~/.npmrc`, and that literal placeholder is then sent as a credential. The
261
+ registry comes from `publishConfig` instead.
262
+
263
+ It also upgrades npm to `^11.5.1` first: Trusted Publishing is a feature of the npm CLI, the npm 10 that ships with
264
+ Node 22 cannot speak OIDC, and on npm 10 the publish dies with `ENEEDAUTH` — which reads like a missing token but is
265
+ really an npm that is too old.
266
+
267
+ </details>
268
+
269
+ ## License
270
+
271
+ MIT, see [LICENSE](./LICENSE).
272
+
273
+ "GitHub Desktop" is a product name of GitHub, Inc. This plugin is not a GitHub product and is not affiliated with
274
+ GitHub; it borrows the layout and nothing else.
package/README.md CHANGED
@@ -1,3 +1,247 @@
1
- # Temporary Holding Version
1
+ # dsh-gitbar-lite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **给 DSH 用的 GitHub Desktop 风格 Git 面板**——装在 DSH **自带的右侧栏**里,和「文件 / 文档 / 聊天」并排。
4
+
5
+ 变更文件列表、勾选提交、Summary + Description、Commit / Commit and Push、一个会随分支状态变化的 Push/Fetch 按钮、舍弃更改进插件自己的回收站、双击看 diff——都在这一栏里。
6
+
7
+ > English: [README.en.md](./README.en.md)
8
+
9
+ ## 为什么是「右侧栏的一个 tab」
10
+
11
+ DSH 的右栏本来就是一个常驻的标签区。Git 面板放这里,意味着它和会话是同一份上下文:**当前会话的工作区就是它看的仓库**,切换会话就跟着切,不用再挑一次目录。
12
+
13
+ 面板很窄(右栏最小 300px,窄视口下会自动全屏),所以它是按窄栏设计的:文件列表单列、路径按「文件名 + 目录」两级显示、提交框只留一个 Summary 输入框加一个可展开的小三角。
14
+
15
+ ## 功能
16
+
17
+ | 功能 | 说明 |
18
+ |---|---|
19
+ | 变更文件列表 | 修改 / 新增 / 删除 / 重命名 / 未跟踪都能识别,每行带 `+n −n` 行数统计 |
20
+ | 勾选 | **勾选就是真的 `git add`**,取消就是 `git restore --staged`。终端里 `git status` 看到的就是同一件事 |
21
+ | 半暂存 | 一个文件索引和工作区都有改动时,勾选框显示成「半选」,点一下把它整体重述为勾上或没勾 |
22
+ | Summary / Description | Summary 常驻一行;最右边一个小三角展开 Description |
23
+ | Commit | 只提交勾上的(因为勾选写进了索引) |
24
+ | Commit and Push | 提交后立刻推送;**推送失败不会说「提交失败」**——提交已经发生了,面板会明说「已提交 xxx,但推送失败」 |
25
+ | 上下文按钮 | 有本地未推送提交时是 **Push**,没有上游时是 **Publish**,否则是 **Fetch** |
26
+ | 舍弃更改 | 单个文件或「全部舍弃」;动手前先把这次改动的 diff 归档,确认框里可以勾「彻底删除」 |
27
+ | 回收站 | 独立 tab。每次舍弃存一份 diff,并记下**是对哪个提交(`abc1234 初始提交`)取的 diff** |
28
+ | diff 预览 | 双击文件,或点回收站里的一条,在右栏开一个 diff 标签页——**永远只占一个**,再点就复用 |
29
+ | 仓库绑定 | 工作区本身是 Git 仓库就自动绑定并记住;不是就让你从工作区里扫出来的仓库里挑,或直接填路径 |
30
+
31
+ ## 前置条件
32
+
33
+ | 需要什么 | 说明 |
34
+ |---|---|
35
+ | DSH `>= 0.2.0-rc.2` | 右侧栏 tab API 是这一版的行为 |
36
+ | Node `>= 22` | 宿主半边用到 `AbortSignal.any` 与 `node:test` |
37
+ | **本机有一个 `git`** | 默认从 `PATH` 找;找不到时面板会明确说「本机找不到 git」,不会静默失败。装在别处可以用 `gitPath` 指定 |
38
+ | 凭据 | Push 用的是**系统 git 的凭据**(credential helper)。先在终端里成功 push 过一次,插件就跟着能推 |
39
+
40
+ 插件不会自己存任何凭据。宿主半边不读 `$DSH_HOME` 里的密钥,也不写。
41
+
42
+ ## 安装
43
+
44
+ ```sh
45
+ dsh plugin --profile <你的 profile> add dsh-gitbar-lite
46
+ ```
47
+
48
+ 装完**重启 DSH**。然后在一个会话里打开右侧栏,引导页上会多一张 **「Git 更改」** 卡片,点它就进面板。
49
+
50
+ <details>
51
+ <summary>从本地克隆安装 / 卸载 / 装完打不开怎么办</summary>
52
+
53
+ **本地目录安装**(改代码时用这个):
54
+
55
+ ```sh
56
+ dsh plugin --profile desktop add link:/绝对路径/dsh-gitbar-lite
57
+ ```
58
+
59
+ **卸载**:
60
+
61
+ ```sh
62
+ dsh plugin --profile <你的 profile> remove dsh-gitbar-lite
63
+ ```
64
+
65
+ **装完 DSH 起不来**:先跑 `node scripts/check-client-bundle.mjs .`。DSH 把
66
+ `exports["./client"]` 当**精确文件路径**读,没有扩展名补全也没有目录回退;指向一个不存在的文件会直接
67
+ `MissingClientBundleError` **中止整个 Host 启动**,而不是「这个插件不显示」。这个脚本就是复刻那条判定,让你不必靠重启去试。
68
+
69
+ </details>
70
+
71
+ ## 使用
72
+
73
+ ```
74
+ 右栏引导页
75
+ ┌──────────────────────────────────┐
76
+ │ ▸ Git 更改 │ ← 引导卡片,点开面板
77
+ │ 查看改动、勾选要提交的文件… │
78
+ └──────────────────────────────────┘
79
+ ↓ 点开
80
+ ┌──────────────────────────────────┐
81
+ │ dsh-gitbar-lite main ↑1 ↓0 │ ← 仓库名 / 分支 / 领先落后
82
+ │ [Push] [↻] [🗄] [⑂] │ ← 上下文按钮、刷新、回收站、换绑
83
+ │ 更改 5 个文件 · 已勾选 2 [全部舍弃] │
84
+ │ ☑ M a.txt +3 −1 ↩│ ← 双击 = 看 diff;↩ = 舍弃这个文件
85
+ │ ▣ M src/big.ts +12 −4 ↩│ ← ▣ = 半暂存
86
+ │ ☐ ? new note.md +7 ↩│
87
+ │ ☐ D docs/old.md −31 ↩│
88
+ │ ☐ R moved.txt ↩│
89
+ │ ┌────────────────────────────┐ ˅ │ ← ˅ 展开 Description
90
+ │ │ Summary(必填) │ │
91
+ │ └────────────────────────────┘ │
92
+ │ [ Commit ] [ Commit and Push ]│
93
+ └──────────────────────────────────┘
94
+ ```
95
+
96
+ - **勾选**:点一下写进索引,再点一下撤出。半选状态点一下会整体勾上,再点整体撤出。
97
+ - **双击文件行** → 右栏开 diff 标签页;再双击别的文件,**复用同一个标签页**。
98
+ - **舍弃**(行尾 ↩ 或「全部舍弃」)→ 弹确认框:
99
+
100
+ ```
101
+ 舍弃「a.txt」的更改?
102
+ · a.txt
103
+ ☐ 彻底删除(不存入回收站)
104
+ 这次的 diff 会存进插件的回收站,之后还能回看是改了什么。
105
+ [ 取消 ] [ 舍弃 ]
106
+ ```
107
+
108
+ **默认不勾「彻底删除」**:这次改动的 diff 会存进回收站。勾上就什么都不留。这个勾选框会记住你上一次的选择。
109
+ - **回收站**(🗄):按时间列出记录,每条显示「多久之前 · 单个文件/全部更改 · 文件数 · +n −n · 基于 `abc1234 初始提交`」。
110
+ 点一条看 diff,行尾的 🗑 删掉它,右上「清空回收站」一次清掉(只清当前仓库,勾上「显示全部仓库」就清全部)。
111
+ - **换绑**(⑂):解除当前绑定。工作区是仓库会重新自动绑定;不是的话回到选择界面。
112
+
113
+ ### 舍弃到底做了什么
114
+
115
+ 顺序是固定的,不能换:
116
+
117
+ 1. 按当前改动生成 diff(已跟踪的文件走 `git diff HEAD`;**未跟踪的新文件由插件自己合成一份 new-file diff**,
118
+ 因为 `git diff HEAD` 看不见它们,而舍弃未跟踪文件就是删掉它——不先合成,回收站里就只剩一条「删了一个文件」的空记录)。
119
+ 2. 存进回收站(除非勾了「彻底删除」)。
120
+ 3. 已跟踪的 `git restore --source=HEAD --staged --worktree`;未跟踪的直接删文件,并把因此变空的目录一起清掉。
121
+
122
+ > **冲突中的文件会被拒绝舍弃**,只给一句提示。在合并中途 `git restore` 到 HEAD 有扰动合并状态的风险,不值得为省一句话去冒。
123
+
124
+ ## 配置
125
+
126
+ | 字段 | 默认 | 说明 |
127
+ |---|---|---|
128
+ | `gitPath` | `""` | 指定 git 可执行文件;空 = 从 `PATH` 找 |
129
+ | `stateDir` | `""` | 插件状态目录;空 = `<DSH_HOME>/dsh-gitbar-lite` |
130
+ | `timeoutMs` | `30000` | 单条 git 命令超时。推到慢远端可以调大 |
131
+ | `maxDiffBytes` | `524288` | 一份归档 diff 的上限;超出按**行**截断并记下丢了多少行 |
132
+ | `maxFileBytes` | `2097152` | 比这大的未跟踪文件只列出来,不读内容、不归档、不出 diff |
133
+ | `trashLimit` | `200` | 回收站保留多少条;超出丢弃最旧的连同它的 diff 文件 |
134
+ | `scanDepth` | `3` | 「不是仓库时」扫描工作区找仓库的目录深度 |
135
+
136
+ 每个字段都是 volatile 的,所以在设置页改了立刻生效、不用重启。
137
+
138
+ **状态存在哪**:`<DSH_HOME>/dsh-gitbar-lite/`(没有 `DSH_HOME` 就回落 `~/.dsh`)——
139
+ `bindings.json`(工作区 → 仓库)、`prefs.json`(那两个勾选偏好)、`trash/index.json` + `trash/<id>.diff`。
140
+ 都是先写临时文件再 rename,所以写到一半崩了也不会把文件截断。
141
+
142
+ ## 工作原理
143
+
144
+ ```
145
+ lib/shared.js 线协议字面量(浏览器半边手抄一份,自检脚本负责比对)
146
+ lib/index.js 插件入口:Config、apply、把路由挂到 connection.fetch
147
+ lib/host/git.js git 执行层:走 ctx.subprocess,解析可执行文件、限流输出、清洗环境
148
+ lib/host/parse.js porcelain v2 / numstat 解析(纯函数)
149
+ lib/host/diff.js diff 合成、按行截断、按文件切分(纯函数)
150
+ lib/host/repo.js 仓库操作:status / stage / commit / push / fetch / fileDiff / discard
151
+ lib/host/store.js 绑定、偏好、回收站(原子写 + 按文件串行化)
152
+ lib/host/workspace.js 会话 → 工作区目录
153
+ lib/host/routes.js 单条 /api 路由 + action 分发 + 错误码映射
154
+ lib/client.js 浏览器半边:三个 tab 类型 + 面板/回收站/diff,手写、无构建
155
+ ```
156
+
157
+ 几个值得写下来的决定:
158
+
159
+ - **一条 `/api` 路由 + `action` 字段**,不是一条路由一个操作。`connection.fetch` 按精确 pathname 匹配,
160
+ 而每次调用本来就要带 `sessionId`;分开写只会把十五个路径字面量摊到两种语言里。
161
+ - **必须挂在 `connection.fetch` 上,绝不挂 `webServer` 的 exact 路由**:web server 会先匹配 exact 路由,
162
+ 而同路径的 exact 路由会遮蔽 `/api` 前缀路由——Host/Origin fence、浏览器 cookie 校验、operator 身份**全在那条前缀路由上**。
163
+ 用 exact 就等于开了一个不需要鉴权的 git 接口。测试与自检脚本都专门盯这一条,并且**反过来验证这条断言是有牙齿的**。
164
+ - **diff tab 用 `dsh-resource://` 固定地址**,不是 page kind。重新打开同一个资源地址会让宿主**聚焦已开的那个 tab**,
165
+ 并把新的 `params` 导航进去;page kind 只在当前 dock pane 内复用,被浮动出去就会多开一个。固定地址本身就是「永远只占一个」。
166
+ - **不发 `-m`,用 `-F -` 把提交信息从 stdin 送进去**:Summary 里有引号、有换行、以 `-` 开头,都只是文本,不是参数。
167
+ - **`:(literal)` pathspec**:否则舍弃一个叫 `docs` 的文件会顺带动 `docs/` 里的所有东西,`a[1].txt` 会被当成通配。
168
+ - **不 import `@deepseek-ai/dsh-client-ui-primitives`**:浏览器半边只 `require('react')`。
169
+ 一个会抛错的 import 会把整个 slot entry 变成空白,而官方插件开发清单也要求客户端半边不导入 Harness Client 包。
170
+ 需要的那几个控件手写,颜色全部走 `--dsw-alias-*` token;图标是**逐 path 抄宿主 `IconBranchOutlineRegular`
171
+ 等 artwork**,所以它和旁边的宿主图标是一套东西。
172
+
173
+ 更多细节(线协议全表、状态文件格式、为什么这么排序、哪些没验证)在 [`docs/CONTRACT.md`](./docs/CONTRACT.md)。
174
+
175
+ ## 验证
176
+
177
+ ```sh
178
+ npm test # 单测 + 真 git 的 e2e + 产物自检
179
+ npm run test:e2e # 只跑真 git 的 e2e(18 个独立仓库,224 项断言)
180
+ npm run check # 只用产物自检(不启 DSH 也能发现会让 DSH 起不来的问题)
181
+ npm run test:live # 探测一个正在运行的 DSH(不在 npm test 里,需要跑着应用)
182
+ ```
183
+
184
+ | 套件 | 覆盖什么 |
185
+ |---|---|
186
+ | `test/parse.test.mjs` | porcelain v2 与 numstat 解析,含重命名、暂存删除、冲突、非 ASCII 路径、带空格的路径 |
187
+ | `test/diff.test.mjs` | diff 合成(含无尾换行、二进制、CRLF)、按行截断不切断多字节字符、按文件切分 |
188
+ | `test/store.test.mjs` | 绑定键归一、绑定/偏好/回收站往返、坏文件容错、并发写不丢更新 |
189
+ | `test/index.test.mjs` | Config 字段与默认值、**路由载体与「只注册一次」**、错误码映射 |
190
+ | `test/client.test.mjs` | 按 manifest 的确切路径加载真实 bundle;三个 tab 类型的 id/kind/guide/keepMounted;每个 slot key 都是注册过的 id;字典与错误码齐全;无 hook 的三个组件按渲染树断言 |
191
+ | `test/e2e.mjs` | 真实 git、真实 handler、真实路由层:勾选真的写索引(之后读 `git status` 回来比对)、提交、push 到本地裸远端、推送被拒、舍弃单文件/全部/重命名/暂存删除、冲突拒绝、回收站增删清空、按仓库过滤、扫描深度 |
192
+ | `test/check.mjs` | manifest 指向的文件都存在且进得了 tarball、patch 层只有一行且没有密钥、两份线协议字面量一致、字典与 `locale/*.json` 一致、每个 `t('字面量')` 都能查到、README/workflow 的存在性与内容 |
193
+ | `scripts/check-client-bundle.mjs` | 复刻 client-modules 的判定:`exports["./client"]` 指向的文件是否真的存在 |
194
+
195
+ **没有真浏览器 harness**,这是有意的:本插件不做任何 DOM 量测、不增强宿主 DOM、没有 portal 到 `document.body` 的东西,
196
+ 而那套夹具的忠实性本身就是负担(同家族的插件曾因为夹具抄错一条 CSS 规则,把 bug 固化成绿色)。
197
+ `test/client.test.mjs` 里无 hook 的三个组件是按**渲染树**断言的,覆盖了勾选状态、确认框默认值、候选人列表这些真正的 UI 规则。
198
+ 如果以后 UI 长出依赖 `:hover` 或需要量测的东西,这里就该重新考虑——那条判断写进了 `docs/CONTRACT.md`。
199
+
200
+ ## 已知限制
201
+
202
+ - 一个工作区绑一个仓库,一个仓库一次推一个远端(有 `origin` 就优先它)。不做子模块、worktree、分支切换、历史列表、stash。
203
+ - Fetch 用的是 `git fetch --prune`,会删掉远端已经删除的分支对应的 remote-tracking ref(和 GitHub Desktop 一致)。
204
+ - 提交会跑你自己的 commit hook。卡住的 hook 是你自己的 hook;`timeoutMs` 是唯一的兜底。
205
+ - 回收站存的是 **diff,不是文件**。恢复要自己手工 apply 那份 diff——「恢复按钮」是我们说好不做的。
206
+ - 二进制文件不归档内容,只记一句 `Binary files … differ`。这很诚实,但那不是备份。
207
+ - 超过 `maxFileBytes` 的未跟踪文件被舍弃时,归档里只有一段说明。确认框目前不会特别提示这一点。
208
+ - 「舍弃未跟踪文件」是直接删除(不进系统回收站)——这是说好的设计:插件自己的回收站承接它,不勾「彻底删除」时能看到内容。
209
+
210
+ ## 发布(维护者)
211
+
212
+ 版本号就是发布按钮:
213
+
214
+ 1. 改 `package.json` 的 `version`。
215
+ 2. 推到 `main`。
216
+
217
+ `.github/workflows/publish.yml` 会跑 `npm test`,然后 `npm view <name>@<version>`——**该版本已经在线就静默跳过**(绿色的 no-op,并提示你 bump)。所以漏改版本号不会静默重发,也不会变成一次红色失败。
218
+
219
+ 认证是 npm 的 **Trusted Publishing(OIDC)**:没有任何 token secret,`id-token: write` 就是全部凭据。
220
+
221
+ <details>
222
+ <summary>一次性配置:在 npmjs.com 上绑定 Trusted Publisher</summary>
223
+
224
+ 包的页面 → Settings → Trusted Publisher,填:
225
+
226
+ | 字段 | 值 |
227
+ |---|---|
228
+ | Publisher | GitHub Actions |
229
+ | Repository | `Ckarefulon/dsh-gitbar-lite` |
230
+ | Workflow filename | `publish.yml` |
231
+ | Environment | (留空) |
232
+
233
+ **首次发布必须手工 `npm publish`**——Trusted Publisher 只能绑到一个已经存在于 registry 上的包名。
234
+
235
+ 工作流刻意**不用** `actions/setup-node` 的 `registry-url`:它会往 `~/.npmrc` 写
236
+ `_authToken=${NODE_AUTH_TOKEN}`,而这个字面占位符会被当成凭据发出去。registry 由 `publishConfig` 提供。
237
+
238
+ 另外它会先把 npm 升到 `^11.5.1`:Trusted Publishing 是 npm CLI 的能力,Node 22 自带的 npm 10 不支持 OIDC 交换,
239
+ 在 npm 10 上会以 `ENEEDAUTH` 失败——那看起来像缺 token,其实是 npm 太旧。
240
+
241
+ </details>
242
+
243
+ ## 许可
244
+
245
+ MIT,见 [LICENSE](./LICENSE)。
246
+
247
+ 「GitHub Desktop」是 GitHub, Inc. 的产品名称;本插件不是 GitHub 的产品,也与 GitHub 无关,只是借用了那种布局。
@@ -0,0 +1,19 @@
1
+ # dsh-gitbar-lite — loader rows.
2
+ #
3
+ # One dual-face plugin row: the node half (exports ".") runs in the Host process
4
+ # and serves this plugin's `/api/dsh-gitbar-lite/rpc` route; the `dsh.client`
5
+ # declaration in package.json makes the browser half (exports "./client") load
6
+ # in the Web GUI as a right-sidebar tab type.
7
+ #
8
+ # `name` must be this package's manifest name verbatim — dsh-client-modules keys
9
+ # every browser row by it, and lib/client.js registers its factory under the
10
+ # same id. `id` is the profile entry id, and it must stay equal to the package
11
+ # name because the settings service keys its descriptors by it and the client
12
+ # half repeats both as literals.
13
+ #
14
+ # `config` is deliberately empty. Every field of `Config` is volatile, so values
15
+ # the user changes live in this profile's own patch layer and survive an upgrade
16
+ # of this package; restating them here would shadow those edits.
17
+ - insert:
18
+ - id: dsh-gitbar-lite
19
+ name: dsh-gitbar-lite