@young1lin/dsh-ui-gitworkbench 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/CHANGELOG_EN.md +43 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +34960 -11826
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +401 -7
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +491 -12
- package/src/client/GitWorkbenchPanel.tsx +1655 -190
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/history-layout.ts +52 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +59 -0
- package/src/client/locales.ts +179 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +447 -7
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录面向使用者的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [0.1.7] - 2026-08-20
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- **History 页签保留两种排布,由你决定用哪种**,默认是并排三栏。0.1.6 把这个页签改成了上下分,理由是实测的:三栏时提交列表只有约 420px,每条标题都带省略号。但它是拿另一个轴换来的——列表从十三行高变成「差异区上面剩多少」。两边各有代价,所以它该是个选项,不是一个修复。开关在 `Branch` 那一行的右端,两个图标按钮;选择记在浏览器里,重开还在。两种排布**各记各的尺寸**:三栏拖的是宽度,上下分拖的是高度,来回切不会互相冲掉。提交行也跟着变形——并排时是两行(作者与时间在标题上方,因为 340px 一栏塞不下 hash、作者、时间和标题四样),上下分时是一行,按 `git log --oneline` 的顺序。
|
|
10
|
+
|
|
11
|
+
### 修复
|
|
12
|
+
|
|
13
|
+
- **提交列表标题栏挤不下时改为换行,不再把搜索框裁在面板边缘**。把那一栏拖窄,标题 + `Filter by` + 搜索框放不下,此前是把搜索框在面板边界处切断——一个露出一半、且没有任何迹象表明还有另一半的控件。
|
|
14
|
+
|
|
15
|
+
## [0.1.6] - 2026-08-20
|
|
16
|
+
|
|
17
|
+
### 新增
|
|
18
|
+
|
|
19
|
+
- **左右并排 diff(IDEA 式)**:变更页的每个文件是两列全文、按行对齐——对齐关系读自 git 的全上下文 diff,不是算出来的。悬浮任意单元格会框出它所属的**变更块**并浮出该块的按钮:未暂存页可**暂存这一块**或**撤回这一块**,已暂存页可**取消暂存这一块**。点击带上该块的 hunk 行号与渲染时的 diff sha,host 据此证明文件自面板画出以来没有变过,否则什么都不做。二进制与超大文件回落到原来的统一视图并说明原因——静默换一种视图看着像坏了,不像有防护。
|
|
20
|
+
- **右列可以直接编辑(仅未暂存层)**。已暂存层不给编辑:改索引意味着写一个背后没有文件的 blob。编辑**显式进入、显式保存**,从不按键即写。保存带上缓冲区所基于的 blob sha,host 拒绝过期写入;你在编辑时文件被别人改动,抽屉抬出「重新加载 / 覆盖」横幅,覆盖会等重新拉取完成,所以校验的是文件此刻真实的样子。轮询照常跑:落在脏缓冲区上的刷新只更新树,不动编辑器。
|
|
21
|
+
- **编辑器换成 CodeMirror 6**:撤销栈、多重选择、文件内查找、Tab 缩进(而不是跳出输入框)。高亮仍由 shiki 提供——旁边的 diff 列本来就是 shiki 画的,再塞一套语法引擎等于为同一个文件的略微不同的配色多付一兆字节。编辑期间**保留 diff 配色**:刚敲的行与 git 已经知道的行读起来是同一种东西。
|
|
22
|
+
- **哪一块可以写,面板自己说**:编辑器最左侧一条 2px 竖线,静息 0.32、光标进入满亮(顺带说明键盘焦点在这个面板)。只读文件不画这条线——Files 页签的编辑器是**打开即可写**,没有按钮居中;而只读文件(CRLF / 非 UTF-8)会把按键**默默吃掉**,一条亮着却拒收的提示比没有提示更糟,所以这条线与 `EditorState.readOnly` 同源于一个布尔。
|
|
23
|
+
- **Blame**:工作区列的侧栏显示人名与短 hash,连续同一提交的行合并成一段(一屏几十行同一个名字念不出任何信息)。点某一行抬出该提交的详情条,一键跳到历史页并带上「这个人在这个文件上的提交」过滤条件。缓冲区一脏就收起——行号已经对不上,一个悄悄指错行的标注比没有标注更坏。
|
|
24
|
+
- **Files 页签**:浏览**整个仓库**,不只是 git 有话说的那些文件——「这一行是谁写的」问的几乎总是今天没人动过的文件。目录树 + 搜索框(多关键字、智能大小写),可读、可 blame、可编辑。**记住你在哪**:展开的目录和打开的文件按工作树各记各的,重启后还在。
|
|
25
|
+
- **图片直接看**:PNG / JPEG / GIF / WebP / BMP / ICO / AVIF / SVG 按**格式规范的魔数**判定后预览(扩展名只用来决定「值不值得问一次」)。SVG 走 `blob:` 的 `<img>`,绝不内联——`<img>` 内的文档是规范定义的非脚本环境,这条规范保证才是 SVG 能安全显示的原因。可切「看源码」。
|
|
26
|
+
- **逐处变更导航**:变更页与历史页的 diff 都有「上一处 / 下一处」按钮和变更计数,键盘是 **F7 / Shift+F7**(IDEA 的拼写)。一个整体只改了 `+1 −1` 的文件靠滚动是找不到的:那一行的底色只有在你已经看着它的时候才看得见。落点上方留三行上下文,到头绕回第一处而不是把按钮变灰。
|
|
27
|
+
- **History 页签改为上下分**:提交列表横跨整个抽屉、每条一行,下半部分是「文件树 + diff」——和变更页同一套布局,只需要学一遍。三栏时列表只有约 420px,**每一条标题都带省略号**,而标题正是读 log 的理由;拖分隔条救不了,总宽固定,只是把窘迫挪个地方。中间的分隔条可上下拖。
|
|
28
|
+
|
|
29
|
+
### 修复
|
|
30
|
+
|
|
31
|
+
- **拒绝保存一个编辑器无法原样往返的文件**:CRLF 或非 UTF-8 的文件不给编辑入口,而不是给一个按下去会重写一堆没人碰过的字节的按钮。
|
|
32
|
+
- **每一个会丢弃缓冲区的手势都先问**:切文件、切页签、关抽屉、切工作树。
|
|
33
|
+
- **一个没有实际发生的离开手势不能解除未保存守卫**。
|
|
34
|
+
- **没有语法定义的文件曾让整个 header slot 崩溃**。
|
|
35
|
+
- **被改短的行是「改变了」,不是「消失了」**:并排视图曾把它读成删除。
|
|
36
|
+
- **上下分之后窗口变矮就再也拖不回来**:列表高度存的是像素,而只有**拖动**时才夹紧。窗口一矮,存下来的高度比整个 body 还高,下半截塌成 0,分隔条被顶到底边**之外**——屏幕上再没有东西可以拖。上限改为按百分比写在样式里,每次布局重新解析;你选的高度是被夹住而不是被改写,窗口拉高它就回来。
|
|
37
|
+
- **History 里文件树与 diff 之间的分隔条被夹死在最小宽度**:上限算的是「抽屉宽 − 邻居宽 − diff 最小宽」,而上下分之后提交列表横跨整个抽屉,这个差是负数。现在「邻居」由几何判定——两个面板只有在一个结束的地方正是另一个开始的地方时才互相占位。
|
|
38
|
+
- **`···`(这条提交有正文)飘到了作者名旁边**:提交标题此前可伸可涨,列表窄时看不出来,铺满抽屉后标题吃掉全部空白,标记被推到一千像素外,读起来像个没来由的省略号。
|
|
39
|
+
- **blame 开关保留 git 自己的措辞**。
|
|
40
|
+
|
|
41
|
+
### 性能
|
|
42
|
+
|
|
43
|
+
- **Files 页签在大仓库上冻结**。在 56000 文件(宿主 `repoTree` 上限 50000)、其中一个目录挂 6000 子目录的临时仓库上复现并量化:
|
|
44
|
+
- **一次点击能往 DOM 里塞无限多行**。每目录的**文件**限了 100,**子目录**没限——展开那个目录渲染 6000 个按钮,875ms 主线程、**628ms 掉帧**,而且随目录大小线性增长。子目录现在同样限量,同样的「还有 N 项」标记,同样的出口:搜索框读全量路径、不走树。同一次点击现在是 123ms、零掉帧、123 行。
|
|
45
|
+
- **整棵树在定时重建**。抽屉每 3–15 秒轮询 `git status`,未跟踪路径数组每次都是新对象——而它是「合并 → 目录树 → 行」这条链的头。内容没变就保持同一个引用之后,每 20 秒静置的脚本时间从 49–92ms(随屏上行数上升)变成平的约 4ms。agent 运行时轮询是 3 秒而不是 15 秒,这一条在那时最要命。
|
|
46
|
+
- **文件浏览器在仓库规模下站得住**:路径列表、目录树与行的推导都是纯函数并有各自的上限,渲染的行数与仓库大小无关。
|
|
47
|
+
|
|
5
48
|
## [0.1.5] - 2026-08-18
|
|
6
49
|
|
|
7
50
|
### 修复
|
package/CHANGELOG_EN.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
User-facing changes, newest first. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows SemVer.
|
|
4
4
|
|
|
5
|
+
## [0.1.7] - 2026-08-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **The History tab keeps both arrangements and you pick**, defaulting to the three columns. 0.1.6 stacked the tab for a measured reason — in three columns the commit list had about 420px and every subject was ellipsised — but it paid for that in the other axis: the list went from thirteen rows tall to whatever is left above the diff. Both cost something, which makes this a choice rather than a fix. The switch is at the end of the `Branch` row, two icon buttons; the choice is remembered per browser. Each arrangement **keeps its own size**: side by side you drag a width, stacked you drag a height, and switching back finds the pane where you left it. The commit row changes shape with it — two lines beside the diff, where a 340px column cannot hold a hash, an author, a date and a subject; one line when it spans the drawer, in `git log --oneline`'s order.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **The commit list's head wraps instead of clipping its search box at the pane's edge.** Dragged narrow, the pane cannot hold a title, a `Filter by` button and a search box on one line, and what it used to do was cut the search box off at the pane boundary — a control showing half of itself, with nothing to say the other half existed.
|
|
14
|
+
|
|
15
|
+
## [0.1.6] - 2026-08-20
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Side-by-side diff (IDEA-style)** for every file in the Changes tab: two columns of the whole file, aligned row by row — the alignment is read off git's full-context diff rather than computed. Hovering any cell outlines the **change block** it belongs to and floats that block's buttons: **stage** or **roll back** the block on the unstaged tab, **unstage** it on the staged one. The click carries the block's hunk-line indices and the sha of the diff as rendered, so the host can prove the file has not moved since the pane drew it — and does nothing if it has. Binary and oversized files fall back to the unified view with a notice: a view that silently changes shape reads as broken, not as guarded.
|
|
20
|
+
- **The right column is editable** (unstaged layer only — editing the index would mean writing a blob with no file behind it). Editing **arms explicitly and saves explicitly**, never per keystroke. A save carries the blob sha the buffer is based on, and the host refuses a stale write; if the file moved while you were typing, the drawer raises a reload-or-overwrite banner, and overwrite waits for the refetch so it is checked against the file as it truly stands. The poll keeps running: a refresh landing on a dirty buffer updates the tree and leaves the editor alone.
|
|
21
|
+
- **The editor is CodeMirror 6 now**: a real undo stack, multiple selections, find-in-file, and Tab that indents instead of leaving the field. Highlighting still comes from shiki — the diff columns beside it are already shiki-painted, and a second grammar engine would cost another megabyte to render the same file in slightly different colours. **Diff tints survive while editing**, so a line you just typed reads as the same kind of thing as a line git already knows about.
|
|
22
|
+
- **The pane says which half takes keystrokes**: a 2px rule down the editor's leading edge, dim at rest and full accent while the caret is inside (which doubles as "this pane has the keyboard"). A read-only file draws no rule — the Files tab's editor is live the moment a file opens, with no button in between, and a CRLF or non-UTF-8 file **swallows** the keystrokes; a mark that appears on a pane which will refuse them is worse than no mark, so it and `EditorState.readOnly` come from one boolean.
|
|
23
|
+
- **Blame**: a gutter beside the working-tree column with the person's name and a short hash, merging consecutive lines from the same commit (forty rows of the same name says nothing). Clicking a line raises that commit's detail strip and offers a one-click jump to History filtered to "this person, on this file". It withdraws the moment the buffer is dirty — once lines have been typed the numbers no longer match the commits, and an annotation quietly pointing at the wrong line is worse than none.
|
|
24
|
+
- **A Files tab** that browses the **whole repository**, not just what git has something to say about — "who wrote this line" is almost always about a file nobody has touched today. Directory tree plus a search box (multi-term, smart case); read, blame and edit. **It remembers where you were**: the expanded folders and the open file are kept per worktree and survive a restart.
|
|
25
|
+
- **Pictures open as pictures**: PNG, JPEG, GIF, WebP, BMP, ICO, AVIF and SVG, identified by the **magic numbers in their format specs** (the extension only decides whether a file is worth asking about). SVG renders through an `<img>` with a `blob:` URL and is never inlined — the document inside an `<img>` is a non-scripted context by specification, and that guarantee is what makes showing SVG safe. A "Source" toggle shows the markup instead.
|
|
26
|
+
- **Walk a diff change by change**: previous/next buttons and a change count on both the Changes and the History diff, on **F7 / Shift+F7** (IDEA's spelling). A file whose whole delta is `+1 −1` cannot be found by scrolling: the tint on that one line is only visible once you are already looking at it. The landing keeps three rows of context above the change, and wraps at either end rather than going dead.
|
|
27
|
+
- **The History tab is stacked**: the commit list spans the whole drawer, one line per commit, with the tree and diff below it — the same arrangement as the Changes tab, so there is one layout to learn. In three columns the list had about 420px and **every subject was ellipsised**, which is the part a log is read for; dragging a divider could not help, since the drawer's width is fixed and it only moves the shortage elsewhere. The split between the two halves drags.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **A file the editor cannot round-trip is refused before the save, not after**: a CRLF or non-UTF-8 file offers no edit affordance at all, rather than a button whose press would rewrite bytes nobody touched.
|
|
32
|
+
- **Every gesture that would drop the buffer asks first**: another file, another tab, closing the drawer, switching worktree.
|
|
33
|
+
- **A leave gesture that did not actually happen can no longer disarm the unsaved-edits guard.**
|
|
34
|
+
- **A file with no grammar crashed the whole header slot.**
|
|
35
|
+
- **A shortened line is changed, not vanished** — the side-by-side view used to read it as a deletion.
|
|
36
|
+
- **The stacked split could not be dragged back open after the window got shorter.** The list's height is stored in pixels and only the *drag* clamped it; make the window shorter and the stored height is taller than everything — the lower half collapses to zero and the handle is pushed past the bottom edge, leaving nothing on screen to pull it back with. The cap now lives in the stylesheet as a percentage, re-resolved on every layout. Your chosen height is capped, not rewritten: make the window tall again and it returns.
|
|
37
|
+
- **The tree/diff divider in History was pinned at its minimum.** The ceiling is `drawer − neighbour − MIN_DIFF`, and the stacked commit list spans the whole drawer, so that came out negative. Neighbourliness is now decided by geometry: two panes take space from each other only when one ends where the other begins.
|
|
38
|
+
- **The "this commit has a body" marker (`···`) floated over to the author's name.** The subject was allowed to grow as well as shrink — invisible while the list was a narrow column, and a defect once it spanned the drawer, where the subject takes all the free space and pushes the marker a thousand pixels right.
|
|
39
|
+
- **The blame toggle keeps git's own wording.**
|
|
40
|
+
|
|
41
|
+
### Performance
|
|
42
|
+
|
|
43
|
+
- **The Files tab froze on a large repository.** Reproduced and measured on a scratch repository of 56,000 files (the host caps `repoTree` at 50,000) with one directory holding 6,000 subdirectories:
|
|
44
|
+
- **One click could put an unbounded number of rows in the DOM.** Files were capped per directory at 100; **subdirectories were not** — expanding that directory rendered 6,000 buttons: 875ms of main thread and a **628ms frozen frame**, growing with the directory. Subdirectories are now capped the same way, with the same "and N others" marker and the same escape hatch: the search box reads the whole path list and ignores the tree. That same click is now 123ms, no stall, 123 rows.
|
|
45
|
+
- **The whole tree rebuilt itself on a timer.** The drawer polls `git status` every 3–15 seconds, and the untracked-path array comes back with a new identity whether or not the repository moved — the head of a chain that runs merge, then directory tree, then rows. Holding it steady while its contents are unchanged took the script time per 20s of sitting still from 49–92ms (rising with the number of rows on screen) to a flat ~4ms. That matters most while an agent is running, when the poll is every 3 seconds rather than 15.
|
|
46
|
+
- **The file browser holds up at repository scale**: the path list, the directory tree and the rows are pure functions with their own caps, and the number of rendered rows does not follow the size of the repository.
|
|
47
|
+
|
|
5
48
|
## [0.1.5] - 2026-08-18
|
|
6
49
|
|
|
7
50
|
### Fixed
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `applyBlocks` sequence: turn "these hunk lines of this block" into a git
|
|
3
|
+
* index or working-tree mutation.
|
|
4
|
+
*
|
|
5
|
+
* The client sends a SELECTION, never patch text — a patch is a file-addressing
|
|
6
|
+
* format, and accepting one from the browser would be a write primitive with a
|
|
7
|
+
* path argument. So the host re-fetches the layer's diff itself, proves the
|
|
8
|
+
* client is describing the same snapshot (sha1 of the fetched text must equal
|
|
9
|
+
* the `diffSha` the pane rendered), and only then emits and applies.
|
|
10
|
+
*
|
|
11
|
+
* The sequence's every branch lives here rather than in `index.ts`, because
|
|
12
|
+
* `index.ts` extends the RPC service class and imports its dsh peers as values
|
|
13
|
+
* — vitest cannot load it. Everything git- or filesystem-shaped is injected
|
|
14
|
+
* (`ApplyBlocksIo`), which is also what lets the git-backed tests drive this
|
|
15
|
+
* exact code with a real git in a temp repo instead of a mock of it.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here builds a destructive command: the whole vocabulary is
|
|
18
|
+
* `git apply`, forward into the index (`--cached`) or reverse out of the index
|
|
19
|
+
* or the working tree (`--reverse`). The one argv that carries file content is
|
|
20
|
+
* the host-written tmpfile, never a client string; the client's `path` is
|
|
21
|
+
* checked with `isSafePathArg` and reaches git only inside the layer-diff
|
|
22
|
+
* fetch, behind `--`, as every other pathspec in this plugin is.
|
|
23
|
+
*
|
|
24
|
+
* @module @young1lin/dsh-ui-gitworkbench/apply-blocks
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { classifyFailure, isSafePathArg } from './git-ops.js';
|
|
28
|
+
import { emitPatch, parsePatch } from './patch-model.js';
|
|
29
|
+
/**
|
|
30
|
+
* Whether a mode applies its patch with `--reverse`, which decides the
|
|
31
|
+
* emission rules: a reverse apply's target holds the patch's post-image (the
|
|
32
|
+
* index for `unstage`, the working tree for `discard`), so unselected lines
|
|
33
|
+
* must be presented the post-image sees them — `emitPatch`'s third argument.
|
|
34
|
+
*/
|
|
35
|
+
function appliesInReverse(mode) {
|
|
36
|
+
return mode !== 'stage';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The mode/layer matrix as argv: which `git apply` spelling carries each
|
|
40
|
+
* mutation, and the one layer each is valid on.
|
|
41
|
+
*
|
|
42
|
+
* @returns the argv head for the mode, or null when the pair is not one the
|
|
43
|
+
* design defines — the caller reports it, never guesses a near one.
|
|
44
|
+
*/
|
|
45
|
+
export function applyArgvFor(mode, layer) {
|
|
46
|
+
if (mode === 'stage' && layer === 'unstaged')
|
|
47
|
+
return ['apply', '--cached'];
|
|
48
|
+
if (mode === 'unstage' && layer === 'staged')
|
|
49
|
+
return ['apply', '--cached', '--reverse'];
|
|
50
|
+
if (mode === 'discard' && layer === 'unstaged')
|
|
51
|
+
return ['apply', '--reverse'];
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* sha1 of a string, hex — the `diffSha` contract.
|
|
56
|
+
*
|
|
57
|
+
* This was a private helper of `index.ts` until the checker needed to be the
|
|
58
|
+
* same code as the producer: `fileSides` stamps the diff it returns and
|
|
59
|
+
* `applyBlocks` re-derives the stamp over its own fresh fetch, and the two ends
|
|
60
|
+
* of that comparison must be one function or the comparison means nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function sha1Hex(text) {
|
|
63
|
+
return createHash('sha1').update(text, 'utf8').digest('hex');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The selector `emitPatch` takes for a block's line indices: a hunk line is
|
|
67
|
+
* selected iff its index appears in `lines`.
|
|
68
|
+
*
|
|
69
|
+
* The indices are positional into the one hunk's `lines` — the same array
|
|
70
|
+
* `side-rows.blockLines` read client-side. Only integers ≥ 0 count: `lines`
|
|
71
|
+
* crosses the RPC boundary untyped, and a stray string or float selecting
|
|
72
|
+
* nothing is the safe direction (an empty selection is a no-op, not an error).
|
|
73
|
+
*/
|
|
74
|
+
export function lineSelector(lines) {
|
|
75
|
+
const list = Array.isArray(lines) ? lines : [];
|
|
76
|
+
const wanted = new Set(list.filter(line => Number.isInteger(line) && line >= 0));
|
|
77
|
+
return (_hunkIndex, lineIndex) => wanted.has(lineIndex);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run one block mutation end to end. Never throws: every failure, including an
|
|
81
|
+
* IO failure, is a result the RPC can carry back as a sentence.
|
|
82
|
+
*
|
|
83
|
+
* The order of the steps is the contract: the stale check before anything is
|
|
84
|
+
* emitted, the multi-hunk guard before anything is applied, `--check` before
|
|
85
|
+
* the apply that matters, and the tmpfile deleted whichever way it ends.
|
|
86
|
+
*/
|
|
87
|
+
export async function runApplyBlocks(io, cwd, path, layer, diffSha, lines, mode) {
|
|
88
|
+
try {
|
|
89
|
+
return await applyBlocksChecked(io, cwd, path, layer, diffSha, lines, mode);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// Nothing may throw across the RPC boundary: a failed helper is a failed
|
|
93
|
+
// operation with a message, not a broken call.
|
|
94
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function applyBlocksChecked(io, cwd, path, layer, diffSha, lines, mode) {
|
|
98
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
99
|
+
return { ok: false, failure: 'invalid', error: `unsafe path argument: ${JSON.stringify(path)}` };
|
|
100
|
+
}
|
|
101
|
+
const argv = applyArgvFor(mode, layer);
|
|
102
|
+
if (argv === null) {
|
|
103
|
+
return { ok: false, failure: 'invalid', error: `cannot ${String(mode)} a block on the ${String(layer)} layer` };
|
|
104
|
+
}
|
|
105
|
+
// The stale check. The selection's line indices have meaning only against
|
|
106
|
+
// the exact diff the pane rendered; a file that changed since makes them
|
|
107
|
+
// point at different lines, so nothing is emitted, let alone applied.
|
|
108
|
+
const diff = await io.layerDiff(path, layer);
|
|
109
|
+
if (sha1Hex(diff) !== diffSha) {
|
|
110
|
+
return { ok: false, failure: 'stale', error: `${path} changed since the diff was loaded; nothing was applied` };
|
|
111
|
+
}
|
|
112
|
+
const file = parsePatch(diff);
|
|
113
|
+
// No hunk at all (empty or binary diff, with a sha that matches): there is
|
|
114
|
+
// nothing to select, which is the empty selection's no-op, not an error.
|
|
115
|
+
if (file === null)
|
|
116
|
+
return { ok: true };
|
|
117
|
+
// Hunk line indices restart per hunk, so "line 1" of a two-hunk diff names a
|
|
118
|
+
// line in EACH hunk. Full context produces one hunk; anything else must be
|
|
119
|
+
// refused whole rather than applied to the wrong lines.
|
|
120
|
+
if (file.hunks.length > 1) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false, failure: 'invalid',
|
|
123
|
+
error: `the diff for ${path} carries ${file.hunks.length} hunks; block operations need the single full-context hunk`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const patch = emitPatch(file, lineSelector(lines), appliesInReverse(mode));
|
|
127
|
+
// An empty selection is not an error, and `git apply` rejects a patch with
|
|
128
|
+
// no hunks — so "nothing selected" must never reach git at all.
|
|
129
|
+
if (patch.length === 0)
|
|
130
|
+
return { ok: true };
|
|
131
|
+
// The patch travels by tmpfile (`git()` spawns with `stdin: 'ignore'`), as
|
|
132
|
+
// the LAST argument of the apply spelling chosen above. Deleted in the
|
|
133
|
+
// finally whatever happens, so a refusal never litters the temp dir.
|
|
134
|
+
const patchFile = await io.writePatch(patch);
|
|
135
|
+
try {
|
|
136
|
+
const check = await io.git(cwd, [...argv, '--check', patchFile]);
|
|
137
|
+
if (check.exitCode !== 0)
|
|
138
|
+
return refusal(check);
|
|
139
|
+
const applied = await io.git(cwd, [...argv, patchFile]);
|
|
140
|
+
if (applied.exitCode !== 0)
|
|
141
|
+
return refusal(applied);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await io.dropPatch(patchFile).catch(() => {
|
|
146
|
+
// Cleanup must never mask the result it follows; a tmpfile that is
|
|
147
|
+
// already gone has done its job.
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** A refused apply: git's own message verbatim, classified for the banner. */
|
|
152
|
+
function refusal(run) {
|
|
153
|
+
const failure = classifyFailure(run.exitCode, run.stderr, run.stdout);
|
|
154
|
+
const error = (run.stderr || run.stdout).trim().slice(-1000);
|
|
155
|
+
// `classifyFailure` answers null only for a zero exit, which is not a
|
|
156
|
+
// refusal and cannot reach here. The key is omitted rather than sent as
|
|
157
|
+
// null anyway, because the gateway's payloads carry no empty fields.
|
|
158
|
+
return failure === null ? { ok: false, error } : { ok: false, failure, error };
|
|
159
|
+
}
|
package/lib/atomic-json.js
CHANGED
|
@@ -19,10 +19,9 @@ function delay(ms) {
|
|
|
19
19
|
* Write a JSON value so a crash can never leave a truncated file behind.
|
|
20
20
|
*
|
|
21
21
|
* The value is staged into `<path>.tmp` and renamed over the destination, which
|
|
22
|
-
* is atomic within a filesystem.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* rename is retried with backoff before the error is surfaced.
|
|
22
|
+
* is atomic within a filesystem. The rename itself goes through
|
|
23
|
+
* {@link renameWithRetry} — the one home of the Windows EPERM backoff, shared
|
|
24
|
+
* with `write-checked.ts`'s worktree writes rather than duplicated per caller.
|
|
26
25
|
* @param ensureDir - creates the containing directory, recursively.
|
|
27
26
|
* @param writeText - writes a file's whole text.
|
|
28
27
|
* @param rename - renames a path over another.
|
|
@@ -34,9 +33,28 @@ export async function saveJsonAtomic(ensureDir, writeText, rename, path, value)
|
|
|
34
33
|
await ensureDir(join(path, '..'));
|
|
35
34
|
const tmp = `${path}.tmp`;
|
|
36
35
|
await writeText(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
36
|
+
await renameWithRetry(rename, delay, tmp, path);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Rename `from` over `to`, retrying the short-lived Windows failures.
|
|
40
|
+
*
|
|
41
|
+
* On Windows a rename over an existing destination fails with EPERM while
|
|
42
|
+
* another handle briefly holds it open (a concurrent read, an antivirus scan,
|
|
43
|
+
* the search indexer); those locks are short-lived, so the rename is retried
|
|
44
|
+
* with backoff before the error is surfaced. Extracted from
|
|
45
|
+
* {@link saveJsonAtomic} when the editable diff needed the same atomic write
|
|
46
|
+
* for worktree files: one mechanism, two callers, not two mechanisms that can
|
|
47
|
+
* drift.
|
|
48
|
+
* @param rename - renames a path over another.
|
|
49
|
+
* @param delay - waits the given milliseconds.
|
|
50
|
+
* @param from - the staged temp path.
|
|
51
|
+
* @param to - the destination.
|
|
52
|
+
* @throws whatever `rename` threw, once the retries are exhausted.
|
|
53
|
+
*/
|
|
54
|
+
export async function renameWithRetry(rename, delay, from, to) {
|
|
37
55
|
for (let attempt = 0;; attempt++) {
|
|
38
56
|
try {
|
|
39
|
-
await rename(
|
|
57
|
+
await rename(from, to);
|
|
40
58
|
return;
|
|
41
59
|
}
|
|
42
60
|
catch (error) {
|
package/lib/blame.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git blame --line-porcelain`, parsed into one record per line.
|
|
3
|
+
*
|
|
4
|
+
* Porcelain is the only blame format worth parsing: the human format packs
|
|
5
|
+
* author, date and code into fixed-width columns that shift with the longest
|
|
6
|
+
* name in the file, and reading it back means guessing where the columns are.
|
|
7
|
+
* The porcelain repeats a full header for every line — verbose on the wire,
|
|
8
|
+
* unambiguous to read.
|
|
9
|
+
*
|
|
10
|
+
* A line nobody has committed yet gets the all-zero sha and git's own English
|
|
11
|
+
* "Not Committed Yet" as its author. That flag is reported separately so the
|
|
12
|
+
* drawer can say it in the reader's language rather than passing git's string
|
|
13
|
+
* through untranslated.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no node, no git, no React. `tests/blame.test.ts` loads it directly.
|
|
16
|
+
*
|
|
17
|
+
* @module @young1lin/dsh-ui-gitworkbench/blame
|
|
18
|
+
*/
|
|
19
|
+
/** A header line: sha, line in the original, line in the final file, [count]. */
|
|
20
|
+
const ENTRY = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/;
|
|
21
|
+
const ZERO_SHA = '0'.repeat(40);
|
|
22
|
+
/**
|
|
23
|
+
* Parse blame porcelain into per-line records, indexed by final line number.
|
|
24
|
+
*
|
|
25
|
+
* @param text - stdout of `git blame --line-porcelain -- <path>`.
|
|
26
|
+
* @returns one entry per line of the file, in file order. Gaps cannot happen
|
|
27
|
+
* in well-formed output, but a truncated stream yields a shorter
|
|
28
|
+
* array rather than a hole — the caller renders what it has.
|
|
29
|
+
*/
|
|
30
|
+
export function parseBlame(text) {
|
|
31
|
+
if (text.length === 0)
|
|
32
|
+
return [];
|
|
33
|
+
const byLine = new Map();
|
|
34
|
+
let line = 0;
|
|
35
|
+
let hash = '';
|
|
36
|
+
let author = '';
|
|
37
|
+
let time = 0;
|
|
38
|
+
let summary = '';
|
|
39
|
+
for (const raw of text.split('\n')) {
|
|
40
|
+
const head = ENTRY.exec(raw);
|
|
41
|
+
if (head !== null) {
|
|
42
|
+
hash = head[1];
|
|
43
|
+
line = Number.parseInt(head[3], 10);
|
|
44
|
+
// Each entry restates its own fields; carrying the previous line's over
|
|
45
|
+
// would attribute a line to whatever came before it in the stream.
|
|
46
|
+
author = '';
|
|
47
|
+
time = 0;
|
|
48
|
+
summary = '';
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (raw.startsWith('author ')) {
|
|
52
|
+
author = raw.slice(7);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (raw.startsWith('author-time ')) {
|
|
56
|
+
const parsed = Number.parseInt(raw.slice(12), 10);
|
|
57
|
+
time = Number.isFinite(parsed) ? parsed : 0;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (raw.startsWith('summary ')) {
|
|
61
|
+
summary = raw.slice(8);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// The content line, which closes the entry. Its text is the file's own and
|
|
65
|
+
// the drawer already has it, so only the provenance is kept.
|
|
66
|
+
if (raw.startsWith('\t') && line > 0) {
|
|
67
|
+
byLine.set(line, {
|
|
68
|
+
hash,
|
|
69
|
+
author,
|
|
70
|
+
time,
|
|
71
|
+
summary,
|
|
72
|
+
uncommitted: hash === ZERO_SHA,
|
|
73
|
+
});
|
|
74
|
+
line = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const highest = byLine.size === 0 ? 0 : Math.max(...byLine.keys());
|
|
78
|
+
const out = [];
|
|
79
|
+
for (let at = 1; at <= highest; at += 1) {
|
|
80
|
+
out.push(byLine.get(at) ?? { hash: '', author: '', time: 0, summary: '', uncommitted: false });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|