@young1lin/dsh-ui-gitworkbench 0.1.13 → 0.1.15
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 +13 -0
- package/CHANGELOG_EN.md +13 -0
- package/README.md +51 -0
- package/lib/client.js +346 -308
- package/lib/index.js +163 -85
- package/lib/repo-root.js +60 -0
- package/package.json +5 -5
- package/src/client/DiffViews.tsx +41 -12
- package/src/client/cr-mark.ts +39 -0
- package/src/client/locales.ts +4 -4
- package/src/client/styles/changes.css +8 -0
- package/src/index.ts +164 -84
- package/src/repo-root.ts +66 -0
package/lib/repo-root.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the drawer runs git: at the repository ROOT, not at the directory the
|
|
3
|
+
* session happened to open.
|
|
4
|
+
*
|
|
5
|
+
* Every path the drawer carries — the tree's entries, the diff's pathspecs,
|
|
6
|
+
* the editor's save target — is REPOSITORY-RELATIVE, because that is what
|
|
7
|
+
* `git status --porcelain` and `git diff --numstat` print wherever they run.
|
|
8
|
+
* Pathspecs and `:path` revisions are the opposite: git resolves them against
|
|
9
|
+
* the process cwd. The two coincide only when the session opened the root
|
|
10
|
+
* itself. A session opened at a subdirectory (a monorepo's `server/`, say)
|
|
11
|
+
* gets a drawer that LISTS the right files and then quietly fails to do
|
|
12
|
+
* anything with them: `git diff HEAD -- server/main.go` run from `server/`
|
|
13
|
+
* looks for `server/server/main.go`, matches nothing, and exits 0 with empty
|
|
14
|
+
* output — a changed file that opens to a blank pane, a stage tick that dies
|
|
15
|
+
* with "pathspec did not match", a blame that cannot find the path in HEAD.
|
|
16
|
+
*
|
|
17
|
+
* Resolving the root once and running everything there makes every
|
|
18
|
+
* repository-relative spelling correct by construction, whatever directory the
|
|
19
|
+
* session opened. The resolve is a real git spawn per RPC entry — folded into
|
|
20
|
+
* the parallel batch where a poll pays for it — and deliberately NOT cached:
|
|
21
|
+
* re-resolving per call is what lets `git init` run inside a subdirectory
|
|
22
|
+
* mid-session and be picked up by the next poll, and a cache would trade that
|
|
23
|
+
* for a spawn that already runs concurrently with the reads it precedes.
|
|
24
|
+
*
|
|
25
|
+
* Pure and git-injected (the `write-checked` pattern) so vitest can pin both
|
|
26
|
+
* halves: the resolution itself, and the git behaviors the whole arrangement
|
|
27
|
+
* rests on.
|
|
28
|
+
*
|
|
29
|
+
* @module @young1lin/dsh-ui-gitworkbench/repo-root
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The repository root of a directory, or null when git knows none.
|
|
33
|
+
*
|
|
34
|
+
* `--show-toplevel` is the discovery git itself uses, so what it returns is by
|
|
35
|
+
* definition where the porcelain paths of commands run in that directory are
|
|
36
|
+
* rooted — including a linked worktree's own root when the directory sits in
|
|
37
|
+
* one. Output is normalized to forward slashes so `join()` behaves the same on
|
|
38
|
+
* every platform (git for Windows already prints them that way).
|
|
39
|
+
* @param git - how to run git.
|
|
40
|
+
* @param cwd - any directory inside the repository.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveRepoRoot(git, cwd) {
|
|
43
|
+
const out = await git(cwd, ['rev-parse', '--show-toplevel']);
|
|
44
|
+
if (out.exitCode !== 0)
|
|
45
|
+
return null;
|
|
46
|
+
return out.stdout.trim().replace(/\\/g, '/') || null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The directory to run git in for a session's workspace: its repository root,
|
|
50
|
+
* or the directory itself when it is not inside a repository.
|
|
51
|
+
*
|
|
52
|
+
* The fallback keeps the failure honest: outside a repository the caller's own
|
|
53
|
+
* git run fails exactly as it did before this module existed, and that error —
|
|
54
|
+
* not a resolution error — is what the reader should see.
|
|
55
|
+
* @param git - how to run git.
|
|
56
|
+
* @param cwd - the directory the session opened.
|
|
57
|
+
*/
|
|
58
|
+
export async function rootedDir(git, cwd) {
|
|
59
|
+
return (await resolveRepoRoot(git, cwd)) ?? cwd;
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"packageManager": "pnpm@10.20.0",
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
69
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
72
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.
|
|
69
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
|
|
73
73
|
"react": "^18.2.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependenciesMeta": {
|
package/src/client/DiffViews.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from 'react'
|
|
5
5
|
|
|
6
6
|
import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
|
|
7
|
+
import { CR, CR_GLYPH, splitOnCr } from './cr-mark.ts'
|
|
7
8
|
import { parsePatch } from '../patch-model.ts'
|
|
8
9
|
import { alignRows, allBlockLines, allBlockTally, blockActionsDisabled, blockCount, blockEdge, blockIsWholeFile, blockLines, blockTally, currentActionBlock, needsFirstBlockClearance, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
|
|
9
10
|
import { anchorFor, blockNearestTo, blockTopsFromRows, blockTopsFromSideRows, countBlocks, scrollTopFor, stepBlockIndex, unifiedBlocks } from './diff-nav.ts'
|
|
@@ -170,14 +171,25 @@ function rowClass(kind: Row['kind']): string {
|
|
|
170
171
|
function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
|
|
171
172
|
if (row.kind === 'hunk') return row.text
|
|
172
173
|
const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
174
|
+
// The trailing-CR marker rides AFTER everything painted: word ranges are
|
|
175
|
+
// char offsets into the row text, so a mid-line glyph would shift them.
|
|
176
|
+
// Mid-line CRs (a CR-only file) stay invisible here; the side pane draws those.
|
|
177
|
+
const crTail = row.text.endsWith(CR)
|
|
178
|
+
? <span className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>
|
|
179
|
+
: null
|
|
180
|
+
if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) {
|
|
181
|
+
return crTail === null ? row.text : <>{row.text}{crTail}</>
|
|
182
|
+
}
|
|
183
|
+
return (<>
|
|
184
|
+
{painted.map((tok, i) => (
|
|
185
|
+
<span
|
|
186
|
+
key={i}
|
|
187
|
+
className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
|
|
188
|
+
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
189
|
+
>{tok.text}</span>
|
|
190
|
+
))}
|
|
191
|
+
{crTail}
|
|
192
|
+
</>)
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
/* ---------- side-by-side diff rendering (working tree only) ---------- */
|
|
@@ -1043,16 +1055,33 @@ function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
|
1043
1055
|
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
1044
1056
|
}
|
|
1045
1057
|
|
|
1046
|
-
/** One
|
|
1058
|
+
/** One text with every carriage return drawn as the CR glyph. No CR means
|
|
1059
|
+
* the text comes back untouched — the common line, on both sides, costs one
|
|
1060
|
+
* `includes`. The glyph spans are aria-hidden and unselectable, so copying a
|
|
1061
|
+
* line copies code, not markers. */
|
|
1062
|
+
function renderWithCrMarks(text: string): ReactNode {
|
|
1063
|
+
const parts = splitOnCr(text)
|
|
1064
|
+
if (parts.length === 1) return text
|
|
1065
|
+
const out: ReactNode[] = [parts[0]!]
|
|
1066
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
1067
|
+
out.push(<span key={`cr${i}`} className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>)
|
|
1068
|
+
out.push(parts[i]!)
|
|
1069
|
+
}
|
|
1070
|
+
return out
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** One cell's Shiki runs, or its plain text when no tokens exist; either way
|
|
1074
|
+
* each carriage return in the cell is drawn, so a line whose only change is
|
|
1075
|
+
* its ending shows the difference instead of two identical-looking cells. */
|
|
1047
1076
|
function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
1048
1077
|
if (cell === null) return ''
|
|
1049
|
-
if (tokens === undefined || tokens.length === 0) return cell.text
|
|
1050
|
-
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
|
|
1078
|
+
if (tokens === undefined || tokens.length === 0) return renderWithCrMarks(cell.text)
|
|
1079
|
+
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return renderWithCrMarks(cell.text)
|
|
1051
1080
|
return tokens.map((tok, i) => (
|
|
1052
1081
|
<span
|
|
1053
1082
|
key={i}
|
|
1054
1083
|
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
1055
|
-
>{tok.text}</span>
|
|
1084
|
+
>{renderWithCrMarks(tok.text)}</span>
|
|
1056
1085
|
))
|
|
1057
1086
|
}
|
|
1058
1087
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making the carriage return visible in diff views.
|
|
3
|
+
*
|
|
4
|
+
* A diff line's text carries the CR byte through verbatim (`fileSides` serves
|
|
5
|
+
* git's own bytes), and a browser draws nothing for it: two cells whose only
|
|
6
|
+
* difference is a line ending render as byte-identical text, so a whole-file
|
|
7
|
+
* CRLF rewrite reads as a wall of changed lines with no visible change in
|
|
8
|
+
* any of them. The fix is not to strip the CR — the byte must survive into
|
|
9
|
+
* every patch the block actions emit — but to draw a glyph at each CR while
|
|
10
|
+
* RENDERING, the way `git diff` spells it as `^M`.
|
|
11
|
+
*
|
|
12
|
+
* The glyph is U+240D (SYMBOL FOR CARRIAGE RETURN), the standard picture of
|
|
13
|
+
* the control character, so a marked line reads as "ends in CR" rather than
|
|
14
|
+
* as a stray character in the code.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no React, no CSS, no git. `tests/cr-mark.test.ts` loads it directly.
|
|
17
|
+
*
|
|
18
|
+
* @module @young1lin/dsh-ui-gitworkbench/cr-mark
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Built rather than escaped so this source file survives any tool that
|
|
22
|
+
* normalises text-mode line endings on its way to disk. */
|
|
23
|
+
export const CR = String.fromCharCode(13)
|
|
24
|
+
|
|
25
|
+
/** What a carriage return is drawn as. */
|
|
26
|
+
export const CR_GLYPH = '\u240d'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split text at every carriage return, so a renderer can interleave one
|
|
30
|
+
* {@link CR_GLYPH} span between consecutive parts.
|
|
31
|
+
*
|
|
32
|
+
* @param text - one cell's text, as the diff model carries it.
|
|
33
|
+
* @returns the parts between CRs; a text with no CR comes back as itself in
|
|
34
|
+
* one piece (length 1), which is the caller's cheap early-out.
|
|
35
|
+
*/
|
|
36
|
+
export function splitOnCr(text: string): readonly string[] {
|
|
37
|
+
if (!text.includes(CR)) return [text]
|
|
38
|
+
return text.split(CR)
|
|
39
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -99,7 +99,7 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
99
99
|
filesDiscardOpen: '丢弃并打开',
|
|
100
100
|
// Read-only rather than withheld: in this view READING the file is the
|
|
101
101
|
// point, and only saving it back would rewrite bytes nobody touched.
|
|
102
|
-
fileReadOnlyCrlf: '这个文件的行尾是 CRLF,只能查看不能编辑(编辑器会把行尾统一成 LF
|
|
102
|
+
fileReadOnlyCrlf: '这个文件的行尾是 CRLF,只能查看不能编辑(编辑器会把行尾统一成 LF,保存时整份文件都会被改写);建议把行尾统一成 LF。',
|
|
103
103
|
fileReadOnlyEncoding: '这个文件不是 UTF-8 编码,只能查看不能编辑:页面上的文字是一次有损解码,保存回去会改写每一个非 ASCII 字节。',
|
|
104
104
|
blameLine: '第 {line} 行',
|
|
105
105
|
blameInHistory: '他在本文件的提交',
|
|
@@ -292,7 +292,7 @@ export const zh: Record<WorkbenchKey, string> = {
|
|
|
292
292
|
blameUncommitted: '尚未提交',
|
|
293
293
|
blameFailed: '这个文件没有可追溯的历史(可能是未跟踪的新文件)。',
|
|
294
294
|
blameTruncated: '文件过长,追溯信息只显示了前面一部分。',
|
|
295
|
-
crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF
|
|
295
|
+
crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF,保存时整份文件都会被改写);建议把行尾统一成 LF。差异里的回车已用 ␍ 标出;查看和按块暂存/撤回不受影响。',
|
|
296
296
|
encodingNotice: '这个文件不是 UTF-8 编码(可能是 GBK、Shift JIS 之类),暂不支持在线编辑:页面上看到的文字是一次有损解码,保存回去会把文件里每一个非 ASCII 字节都改写掉,包括你没动过的行。查看和按块暂存/撤回不受影响。',
|
|
297
297
|
saveFailed: '保存失败',
|
|
298
298
|
saveUnavailable: '当前宿主还不支持保存(需要重启 dsh web 加载新版宿主端)。',
|
|
@@ -339,7 +339,7 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
339
339
|
filesVanished: '{path} is not in the repository any more — deleted, renamed, or on a branch that no longer has it.',
|
|
340
340
|
filesUnsavedAsk: 'You have unsaved edits; opening another file discards them.',
|
|
341
341
|
filesDiscardOpen: 'Discard and open',
|
|
342
|
-
fileReadOnlyCrlf: 'This file uses CRLF line endings, so it opens read-only (the editor normalises them to LF, and a save would rewrite every line).',
|
|
342
|
+
fileReadOnlyCrlf: 'This file uses CRLF line endings, so it opens read-only (the editor normalises them to LF, and a save would rewrite every line); normalising the endings to LF is recommended.',
|
|
343
343
|
fileReadOnlyEncoding: 'This file is not UTF-8, so it opens read-only: the text shown is a lossy decode, and saving it back would rewrite every non-ASCII byte.',
|
|
344
344
|
blameLine: 'Line {line}',
|
|
345
345
|
blameInHistory: 'Their commits on this file',
|
|
@@ -517,7 +517,7 @@ export const en: Record<WorkbenchKey, string> = {
|
|
|
517
517
|
blameUncommitted: 'Not committed yet',
|
|
518
518
|
blameFailed: 'This file has no history to blame (it may be a new, untracked file).',
|
|
519
519
|
blameTruncated: 'The file is long, so blame is shown for the first part only.',
|
|
520
|
-
crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); viewing and block staging/rolling back still work.',
|
|
520
|
+
crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); normalising the endings to LF is recommended. Carriage returns are marked ␍ in the diff; viewing and block staging/rolling back still work.',
|
|
521
521
|
encodingNotice: 'This file is not UTF-8 (GBK, Shift JIS or similar), so the editor is unavailable: the text shown is a lossy decode of it, and saving that back would rewrite every non-ASCII byte in the file, including lines you never touched. Viewing and block staging/rolling back still work.',
|
|
522
522
|
saveFailed: 'Save failed',
|
|
523
523
|
saveUnavailable: 'This host does not support saving yet — restart dsh web to load the new host half.',
|
|
@@ -279,6 +279,14 @@
|
|
|
279
279
|
|
|
280
280
|
.wordAdd { background: var(--gs-add-word); border-radius: 2px; }
|
|
281
281
|
.wordDel { background: var(--gs-del-word); border-radius: 2px; }
|
|
282
|
+
/* A carriage return, drawn. The diff's text carries the CR byte through
|
|
283
|
+
verbatim and a browser paints nothing for it, so a line whose only change
|
|
284
|
+
is its ending rendered as two identical-looking cells; the glyph (U+240D,
|
|
285
|
+
the picture of the control character — `cr-mark.ts`) makes the ending
|
|
286
|
+
visible wherever the diff paints one. Dim and slightly small so a whole
|
|
287
|
+
CRLF file reads as code with quiet markers, not as a wall of symbols, and
|
|
288
|
+
unselectable so copying a line copies code. */
|
|
289
|
+
.crMark { color: var(--gs-fg-faint); font-size: 0.9em; user-select: none; }
|
|
282
290
|
|
|
283
291
|
/* Side-by-side diff pane. One grid holds EVERY row's four cells — a per-row
|
|
284
292
|
grid would let the column boundary drift with each row's content, and a
|