@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.
@@ -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.13",
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.0-rc.2",
70
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.2",
71
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.2",
72
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.2",
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": {
@@ -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
- if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
174
- return painted.map((tok, i) => (
175
- <span
176
- key={i}
177
- className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
178
- style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
179
- >{tok.text}</span>
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 cell's Shiki runs, or its plain text when no tokens exist. */
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
+ }
@@ -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